This is an automated email from the ASF dual-hosted git repository.
sdf-jkl pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-rs.git
The following commit(s) were added to refs/heads/main by this push:
new 06411df16f [Variant] Support list paths in ShreddedSchemaBuilder
(#10635)
06411df16f is described below
commit 06411df16f64a5729e2cd00cc57a1aa2dad8cb82
Author: cakeni <[email protected]>
AuthorDate: Wed Sep 16 21:52:46 2026 +0800
[Variant] Support list paths in ShreddedSchemaBuilder (#10635)
# Which issue does this PR close?
- Closes #10615.
# Rationale for this change
`ShreddedSchemaBuilder` can already parse indexed Variant paths, and
core Variant shredding supports lists, but schema construction currently
panics when a path reaches an index. This prevents callers from
describing list element schemas through the builder.
# What changes are included in this PR?
- Add a list node to the builder's intermediate schema tree.
- Treat numeric indexes as references to the shared list element schema,
including for nested lists.
- Document the index behavior and add coverage for list-of-struct and
nested-list schemas.
- Build the existing list shredding test schema through
`ShreddedSchemaBuilder`.
# Are these changes tested?
Yes. `cargo test -p parquet-variant-compute test_variant_schema_builder`
passes (11 tests).
# Are there any user-facing changes?
Yes. Callers can now use indexed paths such as `items[0].id` when
constructing shredding schemas. This is backward compatible.
## AI assistance
OpenAI Codex assisted with implementation, documentation, and test
drafting. I reviewed the resulting design and changes.
---------
Co-authored-by: cakeni <[email protected]>
---
parquet-variant-compute/src/shred_variant.rs | 129 ++++++++++++++++++++++-----
parquet-variant-compute/src/variant_get.rs | 23 +++++
parquet-variant/src/path.rs | 21 ++++-
parquet-variant/src/utils.rs | 4 +-
parquet-variant/src/variant.rs | 1 +
5 files changed, 151 insertions(+), 27 deletions(-)
diff --git a/parquet-variant-compute/src/shred_variant.rs
b/parquet-variant-compute/src/shred_variant.rs
index 6c4af06d51..4db0098f69 100644
--- a/parquet-variant-compute/src/shred_variant.rs
+++ b/parquet-variant-compute/src/shred_variant.rs
@@ -540,8 +540,9 @@ impl IntoShreddingField for (DataType, bool) {
/// should be shredded and with what types. Fields are nullable by default;
pass
/// a `(data_type, nullable)` pair or a `FieldRef` to control nullability.
///
-/// Note: this builder currently only supports struct fields. List support
-/// will be added in the future.
+/// `[*]` represents the shared element schema of a list, so `items[*].id` and
+/// `items[*].name` describe fields on the same list element struct. Numeric
+/// indexes refer to concrete list elements and are rejected by this builder.
///
/// # Example
///
@@ -568,6 +569,8 @@ impl IntoShreddingField for (DataType, bool) {
/// VariantPath::from_iter([VariantPathElement::from("metrics.cpu")]),
/// &DataType::Float64,
/// )?
+/// // [*] describes the shared schema for every element of a list
+/// .with_path("items[*].id", &DataType::Int64)?
/// .build();
/// Ok(())
/// }
@@ -596,6 +599,8 @@ impl ShreddedSchemaBuilder {
/// * `path` - Anything convertible to [`VariantPath`] (e.g., a `&str`)
/// * `field` - Anything convertible via [`IntoShreddingField`] (e.g.
`FieldRef`,
/// `&DataType`, or `(&DataType, bool)` to control nullability)
+ ///
+ /// List schema paths must use `[*]`; numeric indexes return an error.
pub fn with_path<'a, P, F>(mut self, path: P, field: F) -> Result<Self>
where
P: TryInto<VariantPath<'a>>,
@@ -605,7 +610,7 @@ impl ShreddedSchemaBuilder {
let path: VariantPath<'a> = path
.try_into()
.map_err(|e| ArrowError::InvalidArgumentError(format!("{e:?}")))?;
- self.root.insert_path(&path, field.into_shredding_field());
+ self.root.insert_path(&path, field.into_shredding_field())?;
Ok(self)
}
@@ -626,6 +631,8 @@ enum VariantSchemaNode {
Leaf(ShreddingField),
/// An inner struct node with nested fields
Struct(BTreeMap<String, VariantSchemaNode>),
+ /// An inner list node with a shared element schema
+ List(Box<VariantSchemaNode>),
}
impl Default for VariantSchemaNode {
@@ -636,14 +643,18 @@ impl Default for VariantSchemaNode {
impl VariantSchemaNode {
/// Insert a path into this node with the given data type.
- fn insert_path(&mut self, path: &VariantPath<'_>, field: ShreddingField) {
- self.insert_path_elements(path, field);
+ fn insert_path(&mut self, path: &VariantPath<'_>, field: ShreddingField)
-> Result<()> {
+ self.insert_path_elements(path, field)
}
- fn insert_path_elements(&mut self, segments: &[VariantPathElement<'_>],
field: ShreddingField) {
+ fn insert_path_elements(
+ &mut self,
+ segments: &[VariantPathElement<'_>],
+ field: ShreddingField,
+ ) -> Result<()> {
let Some((head, tail)) = segments.split_first() else {
*self = Self::Leaf(field);
- return;
+ return Ok(());
};
match head {
@@ -651,11 +662,11 @@ impl VariantSchemaNode {
// Ensure this node is a Struct node
let children = match self {
Self::Struct(children) => children,
- Self::Leaf(_) => {
+ Self::Leaf(_) | Self::List(_) => {
*self = Self::Struct(BTreeMap::new());
match self {
Self::Struct(children) => children,
- Self::Leaf(_) => unreachable!(),
+ Self::Leaf(_) | Self::List(_) => unreachable!(),
}
}
};
@@ -663,12 +674,25 @@ impl VariantSchemaNode {
children
.entry(name.to_string())
.or_default()
- .insert_path_elements(tail, field);
+ .insert_path_elements(tail, field)
}
- VariantPathElement::Index { .. } => {
- // List support to be added later; reject for now
- unreachable!("List paths are not supported yet");
+ VariantPathElement::ListElement => {
+ let element = match self {
+ Self::List(element) => element,
+ _ => {
+ *self = Self::List(Box::default());
+ match self {
+ Self::List(element) => element,
+ _ => unreachable!(),
+ }
+ }
+ };
+
+ element.insert_path_elements(tail, field)
}
+ VariantPathElement::Index { index } =>
Err(ArrowError::InvalidArgumentError(format!(
+ "List indexes are not supported in schema paths; use [*], got
[{index}]"
+ ))),
}
}
@@ -689,6 +713,7 @@ impl VariantSchemaNode {
Some(DataType::Struct(Fields::from(child_fields)))
}
}
+ Self::List(element) =>
element.to_shredding_field("item").map(DataType::List),
}
}
@@ -699,7 +724,7 @@ impl VariantSchemaNode {
field.data_type.clone(),
field.nullable,
))),
- Self::Struct(_) => self
+ Self::Struct(_) | Self::List(_) => self
.to_shredding_type()
.map(|data_type| Arc::new(Field::new(name, data_type, true))),
}
@@ -1857,15 +1882,12 @@ mod tests {
]);
// Target schema is List<Struct<id:int64,name:utf8>>
- let object_fields = Fields::from(vec![
- Field::new("id", DataType::Int64, true),
- Field::new("name", DataType::Utf8, true),
- ]);
- let list_schema = DataType::List(Arc::new(Field::new(
- "item",
- DataType::Struct(object_fields),
- true,
- )));
+ let list_schema = ShreddedSchemaBuilder::default()
+ .with_path("[*].id", &DataType::Int64)
+ .unwrap()
+ .with_path("[*].name", &DataType::Utf8)
+ .unwrap()
+ .build();
let result = shred_variant(&input, &list_schema).unwrap();
assert_eq!(result.len(), 3);
@@ -2907,6 +2929,67 @@ mod tests {
Ok(())
}
+ #[test]
+ fn test_variant_schema_builder_list() -> Result<()> {
+ let shredding_type = ShreddedSchemaBuilder::default()
+ .with_path("items[*].id", &DataType::Int64)?
+ .with_path("items[*].name", &DataType::Utf8)?
+ .build();
+
+ assert_eq!(
+ shredding_type,
+ DataType::Struct(Fields::from(vec![Field::new(
+ "items",
+ DataType::new_list(
+ DataType::Struct(Fields::from(vec![
+ Field::new("id", DataType::Int64, true),
+ Field::new("name", DataType::Utf8, true),
+ ])),
+ true,
+ ),
+ true,
+ )]))
+ );
+
+ Ok(())
+ }
+
+ #[test]
+ fn test_variant_schema_builder_nested_lists() -> Result<()> {
+ let shredding_type = ShreddedSchemaBuilder::default()
+ .with_path("matrix[*][*]", (&DataType::Float64, false))?
+ .build();
+
+ assert_eq!(
+ shredding_type,
+ DataType::Struct(Fields::from(vec![Field::new(
+ "matrix",
+ DataType::new_list(DataType::new_list(DataType::Float64,
false), true),
+ true,
+ )]))
+ );
+
+ Ok(())
+ }
+
+ #[test]
+ fn test_variant_schema_builder_rejects_list_indexes() {
+ for (path, index) in [("items[0].id", 0), ("items[42].name", 42)] {
+ let error = ShreddedSchemaBuilder::default()
+ .with_path(path, &DataType::Int64)
+ .err()
+ .unwrap();
+
+ let ArrowError::InvalidArgumentError(message) = error else {
+ panic!("expected InvalidArgumentError, got {error:?}");
+ };
+ assert_eq!(
+ message,
+ format!("List indexes are not supported in schema paths; use
[*], got [{index}]")
+ );
+ }
+ }
+
#[test]
fn test_variant_schema_builder_with_path_variant_path_arg() -> Result<()> {
let path = VariantPath::from_iter([VariantPathElement::from("a.b")]);
diff --git a/parquet-variant-compute/src/variant_get.rs
b/parquet-variant-compute/src/variant_get.rs
index d8f09f0f1b..681c698b10 100644
--- a/parquet-variant-compute/src/variant_get.rs
+++ b/parquet-variant-compute/src/variant_get.rs
@@ -182,6 +182,9 @@ pub(crate) fn follow_shredded_path_element(
None => Ok(missing_path_step()),
}
}
+ VariantPathElement::ListElement =>
Err(ArrowError::InvalidArgumentError(
+ "variant_get does not support [*] path elements".to_string(),
+ )),
}
}
@@ -455,6 +458,15 @@ pub fn variant_get(input: &ArrayRef, options: GetOptions)
-> Result<ArrayRef> {
cast_options,
} = options;
+ if path
+ .iter()
+ .any(|element| matches!(element, VariantPathElement::ListElement))
+ {
+ return Err(ArrowError::InvalidArgumentError(
+ "variant_get does not support [*] path elements".to_string(),
+ ));
+ }
+
shredded_get_path(&variant_array, &path, as_type.as_deref(), &cast_options)
}
@@ -2474,6 +2486,17 @@ mod test {
);
}
+ #[test]
+ fn test_variant_get_list_element_wildcard_is_invalid_argument() {
+ let (unshredded, _) = create_variant_get_as_variant_test_data();
+ let options =
GetOptions::new_with_path(VariantPath::try_from("field_name[*]").unwrap());
+ let err = variant_get(&unshredded, options).unwrap_err();
+ assert!(
+ matches!(err, ArrowError::InvalidArgumentError(_)),
+ "expected InvalidArgumentError, got {err:?}"
+ );
+ }
+
#[test]
fn test_variant_get_missing_path_as_variant_annotates_value_non_nullable()
{
let (unshredded, shredded) = create_variant_get_as_variant_test_data();
diff --git a/parquet-variant/src/path.rs b/parquet-variant/src/path.rs
index cd41c1cdd9..64aca085c5 100644
--- a/parquet-variant/src/path.rs
+++ b/parquet-variant/src/path.rs
@@ -160,7 +160,7 @@ impl<'a> Deref for VariantPath<'a> {
}
}
-/// Element of a [`VariantPath`] that can be a field name or an index.
+/// Element of a [`VariantPath`] that can be a field name, an index, or a list
element wildcard.
///
/// See [`VariantPath`] for more details and examples.
#[derive(Debug, Clone, PartialEq)]
@@ -169,6 +169,8 @@ pub enum VariantPathElement<'a> {
Field { name: Cow<'a, str> },
/// Access the list element at `index`
Index { index: usize },
+ /// Match the shared element schema of a list (`[*]`)
+ ListElement,
}
impl<'a> VariantPathElement<'a> {
@@ -180,6 +182,10 @@ impl<'a> VariantPathElement<'a> {
pub fn index(index: usize) -> VariantPathElement<'a> {
VariantPathElement::Index { index }
}
+
+ pub fn list_element() -> VariantPathElement<'a> {
+ VariantPathElement::ListElement
+ }
}
// Conversion utilities for `VariantPathElement` from string types
@@ -288,6 +294,15 @@ mod tests {
]);
assert_eq!(path, expected);
+ // list wildcard is distinct from a quoted field named "*"
+ let path = VariantPath::try_from("foo[*]['*']").unwrap();
+ let expected = VariantPath::from_iter([
+ VariantPathElement::field("foo"),
+ VariantPathElement::list_element(),
+ VariantPathElement::field("*"),
+ ]);
+ assert_eq!(path, expected);
+
// invalid index will be treated as field
let path = VariantPath::try_from("foo.bar['abc'][\"def\"]").unwrap();
let expected = VariantPath::from_iter([
@@ -339,13 +354,13 @@ mod tests {
let err = VariantPath::try_from("foo.bar[123abc]").unwrap_err();
assert_eq!(
err.to_string(),
- "Parser error: Invalid token in bracket request: `123abc`.
Expected a quoted string or a number(e.g., `['field']` or `[123]`)"
+ "Parser error: Invalid token in bracket request: `123abc`.
Expected `*`, a quoted string, or a number(e.g., `[*]`, `['field']`, or
`[123]`)"
);
let err = VariantPath::try_from("foo.bar[abc]").unwrap_err();
assert_eq!(
err.to_string(),
- "Parser error: Invalid token in bracket request: `abc`. Expected a
quoted string or a number(e.g., `['field']` or `[123]`)"
+ "Parser error: Invalid token in bracket request: `abc`. Expected
`*`, a quoted string, or a number(e.g., `[*]`, `['field']`, or `[123]`)"
);
// Out-of-range integer indexes are invalid path tokens.
diff --git a/parquet-variant/src/utils.rs b/parquet-variant/src/utils.rs
index 10f0a5a721..782f718cfc 100644
--- a/parquet-variant/src/utils.rs
+++ b/parquet-variant/src/utils.rs
@@ -267,10 +267,12 @@ fn parse_in_bracket(s: &str, i: usize) ->
Result<(VariantPathElement<'_>, usize)
{
// Quoted field name, e.g., ['field'] or ['123'] or ["123"]
VariantPathElement::field(inner.to_string())
+ } else if unescaped == "*" {
+ VariantPathElement::list_element()
} else {
let Ok(idx) = unescaped.parse() else {
return Err(ArrowError::ParseError(format!(
- "Invalid token in bracket request: `{unescaped}`. Expected a
quoted string or a number(e.g., `['field']` or `[123]`)"
+ "Invalid token in bracket request: `{unescaped}`. Expected
`*`, a quoted string, or a number(e.g., `[*]`, `['field']`, or `[123]`)"
)));
};
VariantPathElement::index(idx)
diff --git a/parquet-variant/src/variant.rs b/parquet-variant/src/variant.rs
index dcb81de490..6e57e0bee5 100644
--- a/parquet-variant/src/variant.rs
+++ b/parquet-variant/src/variant.rs
@@ -1568,6 +1568,7 @@ impl<'m, 'v> Variant<'m, 'v> {
.try_fold(self.clone(), |output, element| match element {
VariantPathElement::Field { name } =>
output.get_object_field(name),
VariantPathElement::Index { index } =>
output.get_list_element(*index),
+ VariantPathElement::ListElement => None,
})
}
}