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 09a0668c fix(spec): rewrite field-scoped options on column rename
(#635)
09a0668c is described below
commit 09a0668c9cffbf89c0b6821268bf027ded5e6550
Author: kid <[email protected]>
AuthorDate: Mon Aug 3 11:41:32 2026 +0800
fix(spec): rewrite field-scoped options on column rename (#635)
---
crates/paimon/src/spec/aggregation.rs | 162 ++++++++++++++++++++++++++--
crates/paimon/src/spec/schema.rs | 192 +++++++++++++++++++++++++++++++++-
2 files changed, 343 insertions(+), 11 deletions(-)
diff --git a/crates/paimon/src/spec/aggregation.rs
b/crates/paimon/src/spec/aggregation.rs
index 0de0f86e..194d7952 100644
--- a/crates/paimon/src/spec/aggregation.rs
+++ b/crates/paimon/src/spec/aggregation.rs
@@ -33,6 +33,10 @@ const DISTINCT_SUFFIX: &str = ".distinct";
const SEQUENCE_GROUP_SUFFIX: &str = ".sequence-group";
const NESTED_KEY_SUFFIX: &str = ".nested-key";
const COUNT_LIMIT_SUFFIX: &str = ".count-limit";
+const MAP_STORAGE_LAYOUT_SUFFIX: &str = ".map.storage-layout";
+const MAP_SHARED_SHREDDING_MAX_COLUMNS_SUFFIX: &str =
".map.shared-shredding.max-columns";
+const MAP_SHARED_SHREDDING_COLUMN_PLACEMENT_POLICY_SUFFIX: &str =
+ ".map.shared-shredding.column-placement-policy";
/// Minimal aggregation mode recognized by the current Rust implementation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -278,14 +282,34 @@ fn parse_field_scoped_option_key(key: &str) ->
Option<(&str, FieldScopedOptionKi
None
}
-/// Field-scoped aggregation option suffixes whose key names a single column,
-/// so the column rename/drop path can keep them in sync with the schema.
-const FIELD_SCOPED_RENAMEABLE_SUFFIXES: [&str; 2] =
- [AGG_FUNCTION_SUFFIX, LIST_AGG_DELIMITER_SUFFIX];
-
-/// Rename a column inside field-scoped aggregation option KEYS, mirroring
-/// Java `SchemaManager.applyRenameColumnsToOptions` (case 2): the value is
-/// unchanged, only `fields.<old>.<suffix>` -> `fields.<new>.<suffix>`.
+/// Field-scoped option suffixes whose key names a single column
+/// (`fields.<col>.<suffix>`) while the value carries no column names, so the
+/// column rename/drop path can keep them in sync with the schema by rewriting
+/// the key only. Mirrors case 2 of Java
+/// `SchemaManager.applyRenameColumnsToOptions` (Java's case-2 list; the
+/// aggregation/partial-update engines do not accept every entry at create
+/// time, but the keys can still arrive in Java-written metadata).
+const FIELD_SCOPED_RENAMEABLE_SUFFIXES: [&str; 7] = [
+ AGG_FUNCTION_SUFFIX,
+ IGNORE_RETRACT_SUFFIX,
+ DISTINCT_SUFFIX,
+ LIST_AGG_DELIMITER_SUFFIX,
+ MAP_STORAGE_LAYOUT_SUFFIX,
+ MAP_SHARED_SHREDDING_MAX_COLUMNS_SUFFIX,
+ MAP_SHARED_SHREDDING_COLUMN_PLACEMENT_POLICY_SUFFIX,
+];
+
+/// Field-scoped option suffixes where BOTH the key's comma-separated field
+/// list and the value may name columns (`fields.<col,col,...>.<suffix>`).
+/// Mirrors case 3 of Java `SchemaManager.applyRenameColumnsToOptions`.
+const FIELD_SCOPED_FIELD_LIST_SUFFIXES: [&str; 2] = [SEQUENCE_GROUP_SUFFIX,
NESTED_KEY_SUFFIX];
+
+/// Rename a column inside field-scoped option keys (and, for field-list
+/// options, values), mirroring Java
`SchemaManager.applyRenameColumnsToOptions`:
+///
+/// - case 2: `fields.<old>.<suffix>` -> `fields.<new>.<suffix>`, value kept
+/// - case 3 (`sequence-group`, `nested-key`): every comma-separated entry
+/// equal to `old` is renamed in BOTH the key's field list and the value
pub(crate) fn rename_field_scoped_options(
options: &mut HashMap<String, String>,
old: &str,
@@ -297,6 +321,41 @@ pub(crate) fn rename_field_scoped_options(
options.insert(format!("{FIELDS_PREFIX}{new}{suffix}"), value);
}
}
+
+ // Collect first: mutating the map while iterating it is not allowed, and
+ // options that don't reference `old` must stay untouched.
+ let mut rewritten = Vec::new();
+ for (key, value) in options.iter() {
+ let Some(suffix) = FIELD_SCOPED_FIELD_LIST_SUFFIXES
+ .iter()
+ .find(|suffix| key.starts_with(FIELDS_PREFIX) &&
key.ends_with(**suffix))
+ else {
+ continue;
+ };
+ let key_fields = &key[FIELDS_PREFIX.len()..key.len() - suffix.len()];
+ let new_key_fields = rename_in_field_list(key_fields, old, new);
+ let new_value = rename_in_field_list(value, old, new);
+ if new_key_fields != key_fields || new_value != *value {
+ rewritten.push((
+ key.clone(),
+ format!("{FIELDS_PREFIX}{new_key_fields}{suffix}"),
+ new_value,
+ ));
+ }
+ }
+ for (old_key, new_key, new_value) in rewritten {
+ options.remove(&old_key);
+ options.insert(new_key, new_value);
+ }
+}
+
+/// Rename exact matches of `old` inside a comma-separated field list,
+/// preserving order and any entries that don't match.
+fn rename_in_field_list(list: &str, old: &str, new: &str) -> String {
+ list.split(',')
+ .map(|field| if field == old { new } else { field })
+ .collect::<Vec<_>>()
+ .join(",")
}
/// Remove a dropped column's field-scoped aggregation option keys so no
@@ -724,4 +783,91 @@ mod tests {
}
}
}
+
+ #[test]
+ fn test_rename_field_scoped_options_case2_rewrites_key_only() {
+ let mut options: HashMap<String, String> = [
+ ("fields.a.aggregate-function", "sum"),
+ ("fields.a.ignore-retract", "true"),
+ ("fields.a.distinct", "true"),
+ ("fields.a.list-agg-delimiter", ";"),
+ ("fields.a.map.storage-layout", "shared-shredding"),
+ ("fields.a.map.shared-shredding.max-columns", "64"),
+ (
+ "fields.a.map.shared-shredding.column-placement-policy",
+ "even",
+ ),
+ // Same leading characters but a different column: must not move.
+ ("fields.ab.aggregate-function", "max"),
+ ]
+ .into_iter()
+ .map(|(key, value)| (key.to_string(), value.to_string()))
+ .collect();
+
+ rename_field_scoped_options(&mut options, "a", "b");
+
+ for (suffix, value) in [
+ ("aggregate-function", "sum"),
+ ("ignore-retract", "true"),
+ ("distinct", "true"),
+ ("list-agg-delimiter", ";"),
+ ("map.storage-layout", "shared-shredding"),
+ ("map.shared-shredding.max-columns", "64"),
+ ("map.shared-shredding.column-placement-policy", "even"),
+ ] {
+ assert_eq!(
+ options
+ .get(&format!("fields.b.{suffix}"))
+ .map(String::as_str),
+ Some(value),
+ "expected fields.b.{suffix} to carry the original value"
+ );
+ assert!(
+ !options.contains_key(&format!("fields.a.{suffix}")),
+ "old key fields.a.{suffix} should be gone"
+ );
+ }
+ assert_eq!(
+ options
+ .get("fields.ab.aggregate-function")
+ .map(String::as_str),
+ Some("max")
+ );
+ }
+
+ #[test]
+ fn test_rename_field_scoped_options_case3_rewrites_key_and_value() {
+ let mut options: HashMap<String, String> = [
+ // Rename hits both the key's field list and the value.
+ ("fields.g1,g2.sequence-group", "g2,price"),
+ // No reference to the renamed column: must stay untouched.
+ ("fields.other.sequence-group", "price"),
+ // nested-key: the column can appear in the key and in the value.
+ ("fields.g2.nested-key", "g2"),
+ ]
+ .into_iter()
+ .map(|(key, value)| (key.to_string(), value.to_string()))
+ .collect();
+
+ rename_field_scoped_options(&mut options, "g2", "g3");
+
+ assert_eq!(
+ options
+ .get("fields.g1,g3.sequence-group")
+ .map(String::as_str),
+ Some("g3,price")
+ );
+ assert_eq!(
+ options
+ .get("fields.other.sequence-group")
+ .map(String::as_str),
+ Some("price")
+ );
+ assert_eq!(
+ options.get("fields.g3.nested-key").map(String::as_str),
+ Some("g3")
+ );
+ assert!(!options.contains_key("fields.g1,g2.sequence-group"));
+ assert!(!options.contains_key("fields.g2.nested-key"));
+ }
}
diff --git a/crates/paimon/src/spec/schema.rs b/crates/paimon/src/spec/schema.rs
index b3c48d13..3e2e024b 100644
--- a/crates/paimon/src/spec/schema.rs
+++ b/crates/paimon/src/spec/schema.rs
@@ -372,9 +372,10 @@ impl TableSchema {
name,
&new_name,
);
- // Field-scoped aggregation options encode the column in
the
- // key (`fields.<col>.aggregate-function` /
`.list-agg-delimiter`),
- // so they must be rewritten too, mirroring Java
+ // Field-scoped options encode column names in the key
+ // (`fields.<col>.aggregate-function`,
`fields.<cols>.sequence-group`,
+ // ...) and, for field-list options, in the value too, so
they
+ // must be rewritten as well, mirroring Java
// `SchemaManager.applyRenameColumnsToOptions`.
rename_field_scoped_options(&mut new_schema.options, name,
&new_name);
}
@@ -3297,6 +3298,191 @@ mod tests {
);
}
+ #[test]
+ fn test_rename_column_rewrites_sequence_group_options() {
+ // `sequence-group` is rejected by Rust's create-time merge-engine
+ // validation, so the fixture carries the option on a table without
+ // `merge-engine` — the shape in which Java-written metadata arrives.
+ let table_schema = TableSchema::new(
+ 0,
+ &Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("version", DataType::Int(IntType::new()))
+ .column("source_order", DataType::Int(IntType::new()))
+ .column("price", DataType::Int(IntType::new()))
+ .column("quantity", DataType::Int(IntType::new()))
+ .primary_key(["id"])
+ .option(
+ "fields.version,source_order.sequence-group",
+ "price,quantity",
+ )
+ .build()
+ .unwrap(),
+ );
+
+ // Rename a sequence field (key side) and a protected field (value
side).
+ let new_schema = table_schema
+ .apply_changes(vec![
+ crate::spec::SchemaChange::rename_column(
+ "source_order".to_string(),
+ "order_seq".to_string(),
+ ),
+ crate::spec::SchemaChange::rename_column("price".to_string(),
"amount".to_string()),
+ ])
+ .unwrap();
+
+ assert_eq!(
+ new_schema
+ .options()
+ .get("fields.version,order_seq.sequence-group")
+ .map(String::as_str),
+ Some("amount,quantity")
+ );
+ assert_eq!(
+ new_schema
+ .options()
+ .get("fields.version,source_order.sequence-group"),
+ None
+ );
+ }
+
+ #[test]
+ fn test_rename_column_rewrites_nested_key_options() {
+ // Same rationale as the sequence-group test: `nested-key` cannot be
+ // created through Rust, but Java-written tables carry it.
+ let table_schema = TableSchema::new(
+ 0,
+ &Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("profile", DataType::Int(IntType::new()))
+ .column("region", DataType::Int(IntType::new()))
+ .primary_key(["id"])
+ .option("fields.profile.nested-key", "region")
+ .build()
+ .unwrap(),
+ );
+
+ let new_schema = table_schema
+ .apply_changes(vec![
+
crate::spec::SchemaChange::rename_column("profile".to_string(),
"info".to_string()),
+ crate::spec::SchemaChange::rename_column("region".to_string(),
"area".to_string()),
+ ])
+ .unwrap();
+
+ assert_eq!(
+ new_schema
+ .options()
+ .get("fields.info.nested-key")
+ .map(String::as_str),
+ Some("area")
+ );
+ assert_eq!(new_schema.options().get("fields.profile.nested-key"),
None);
+ }
+
+ #[test]
+ fn test_rename_column_rewrites_remaining_case2_suffixes() {
+ // `ignore-retract` / `distinct` are rejected by Rust's create-time
+ // merge-engine validation; the fixture carries them (and the
+ // map-shredding options, which Rust does honor) as plain metadata,
+ // like a Java-written schema.
+ let table_schema = TableSchema::new(
+ 0,
+ &Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("price", DataType::Int(IntType::new()))
+ .column(
+ "props",
+ DataType::Map(MapType::new(
+ DataType::VarChar(VarCharType::string_type()),
+ DataType::Int(IntType::new()),
+ )),
+ )
+ .primary_key(["id"])
+ .option("fields.price.ignore-retract", "true")
+ .option("fields.price.distinct", "true")
+ .option("fields.props.map.storage-layout", "shared-shredding")
+ .option("fields.props.map.shared-shredding.max-columns", "64")
+ .build()
+ .unwrap(),
+ );
+
+ let new_schema = table_schema
+ .apply_changes(vec![
+ crate::spec::SchemaChange::rename_column("price".to_string(),
"amount".to_string()),
+ crate::spec::SchemaChange::rename_column(
+ "props".to_string(),
+ "properties".to_string(),
+ ),
+ ])
+ .unwrap();
+
+ for (key, value) in [
+ ("fields.amount.ignore-retract", "true"),
+ ("fields.amount.distinct", "true"),
+ ("fields.properties.map.storage-layout", "shared-shredding"),
+ ("fields.properties.map.shared-shredding.max-columns", "64"),
+ ] {
+ assert_eq!(
+ new_schema.options().get(key).map(String::as_str),
+ Some(value),
+ "expected {key} to follow the rename"
+ );
+ }
+ for old_key in [
+ "fields.price.ignore-retract",
+ "fields.price.distinct",
+ "fields.props.map.storage-layout",
+ "fields.props.map.shared-shredding.max-columns",
+ ] {
+ assert_eq!(new_schema.options().get(old_key), None);
+ }
+ }
+
+ #[test]
+ fn test_rename_column_field_scoped_options_match_whole_names_only() {
+ // `price2` merely starts with `price`: its keys must not move when
+ // `price` is renamed, while a value-side exact reference to `price`
+ // still gets rewritten (Java `applyNotNestedColumnRename` semantics).
+ let table_schema = TableSchema::new(
+ 0,
+ &Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("price", DataType::Int(IntType::new()))
+ .column("price2", DataType::Int(IntType::new()))
+ .primary_key(["id"])
+ .option("fields.price2.sequence-group", "price")
+ .option("fields.price2.aggregate-function", "sum")
+ .build()
+ .unwrap(),
+ );
+
+ let new_schema = table_schema
+ .apply_changes(vec![crate::spec::SchemaChange::rename_column(
+ "price".to_string(),
+ "amount".to_string(),
+ )])
+ .unwrap();
+
+ assert_eq!(
+ new_schema
+ .options()
+ .get("fields.price2.sequence-group")
+ .map(String::as_str),
+ Some("amount")
+ );
+ assert_eq!(
+ new_schema
+ .options()
+ .get("fields.price2.aggregate-function")
+ .map(String::as_str),
+ Some("sum")
+ );
+ assert_eq!(
+ new_schema.options().get("fields.amount.sequence-group"),
+ None
+ );
+ }
+
fn assert_primary_key_index_column_changes_rejected(
table_schema: &TableSchema,
column_name: &str,