This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new a76678363 feat(RTAS) Assign fresh ids (#3066)
a76678363 is described below

commit a76678363835a3035c38b9ab53785e9d9b762d24
Author: Xander <[email protected]>
AuthorDate: Sun Oct 11 14:25:41 2026 +0000

    feat(RTAS) Assign fresh ids (#3066)
    
    * feat(RTAS) Assign fresh ids
    
    * update tests with more types
    
    * avoid clone
    
    * document assign better
    
    * relax test
    
    * additional test
    
    * comment
    
    * Add debug assert
    
    * lowercase
    
    * doc
    
    * not nested
    
    * test
    
    * test comments
    
    * comments
    
    * starts_from
---
 crates/iceberg/src/spec/schema/assign_fresh_ids.rs | 520 +++++++++++++++++++++
 crates/iceberg/src/spec/schema/id_reassigner.rs    |   4 +-
 crates/iceberg/src/spec/schema/mod.rs              |   3 +
 3 files changed, 525 insertions(+), 2 deletions(-)

diff --git a/crates/iceberg/src/spec/schema/assign_fresh_ids.rs 
b/crates/iceberg/src/spec/schema/assign_fresh_ids.rs
new file mode 100644
index 000000000..94f303068
--- /dev/null
+++ b/crates/iceberg/src/spec/schema/assign_fresh_ids.rs
@@ -0,0 +1,520 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use super::utils::try_insert_field;
+use super::*;
+use crate::error::invalid_data;
+
+/// Reassigns `schema`'s field ids, reusing ids from `base` for fields whose 
full name is unchanged
+/// and drawing fresh ids from `last_column_id + 1` upwards for everything 
else.
+///
+/// `last_column_id` must be every id the table has ever assigned, not merely 
`base`'s highest: pass
+/// `table_metadata.last_column_id()`, which also reserves the ids of columns 
already dropped from
+/// `base`. A reused id does not consume a fresh one, so a too-low 
`last_column_id` fails in one of
+/// two ways. Falling below `base`'s own ids is rejected here. Landing on the 
id of a column already
+/// dropped from `base` cannot be detected from `base` alone: nothing here or 
in `build()` notices,
+/// and a new column silently inherits a retired id. Passing a schema's 
`highest_field_id()` instead
+/// of the table's `last_column_id()` is the usual way to hit the latter.
+///
+/// The returned `schema_id` is carried over unchanged and is not 
authoritative; it is arbitrated by
+/// 
[`TableMetadataBuilder::add_schema`](crate::spec::TableMetadataBuilder::add_schema).
+pub(crate) fn assign_fresh_ids(
+    schema: Schema,
+    base: &Schema,
+    last_column_id: i32,
+) -> Result<Schema> {
+    if last_column_id < base.highest_field_id() {
+        return Err(invalid_data!(
+            "last_column_id ({last_column_id}) is below 
base.highest_field_id() ({}); pass table_metadata.last_column_id()",
+            base.highest_field_id()
+        ));
+    }
+
+    let start_from = last_column_id
+        .checked_add(1)
+        .ok_or_else(|| invalid_data!("Field ID overflowed, cannot add more 
fields"))?;
+
+    let Schema {
+        r#struct,
+        schema_id,
+        identifier_field_ids,
+        alias_to_id,
+        id_to_name,
+        ..
+    } = schema;
+    let mut assigner = AssignFreshIds::new(id_to_name, base, start_from);
+    let fields = assigner.assign_fields(r#struct.fields().to_vec())?;
+    let identifier_field_ids = 
assigner.apply_to_identifier_fields(identifier_field_ids)?;
+    let alias_to_id = assigner.apply_to_aliases(alias_to_id)?;
+
+    Schema::builder()
+        .with_schema_id(schema_id)
+        .with_fields(fields)
+        .with_identifier_field_ids(identifier_field_ids)
+        .with_alias(alias_to_id)
+        .build()
+}
+
+struct AssignFreshIds {
+    next_field_id: i32,
+    target_names: HashMap<i32, String>,
+    base_ids: HashMap<String, i32>,
+    old_to_new_id: HashMap<i32, i32>,
+}
+
+impl AssignFreshIds {
+    fn new(target_names: HashMap<i32, String>, base: &Schema, start_from: i32) 
-> Self {
+        Self {
+            next_field_id: start_from,
+            target_names,
+            base_ids: base
+                .field_id_to_name_map()
+                .iter()
+                .map(|(id, name)| (name.clone(), *id))
+                .collect(),
+            old_to_new_id: HashMap::new(),
+        }
+    }
+
+    /// Returns `base`'s id when the field's full name is unchanged, otherwise 
consumes a fresh id by
+    /// advancing `next_field_id`.
+    fn resolve_or_assign_id(&mut self, old_id: i32) -> Result<i32> {
+        if let Some(id) = self
+            .target_names
+            .get(&old_id)
+            .and_then(|name| self.base_ids.get(name))
+        {
+            return Ok(*id);
+        }
+
+        let id = self.next_field_id;
+        self.next_field_id = self
+            .next_field_id
+            .checked_add(1)
+            .ok_or_else(|| invalid_data!("Field ID overflowed, cannot add more 
fields"))?;
+        Ok(id)
+    }
+
+    fn assign_fields(&mut self, fields: Vec<NestedFieldRef>) -> 
Result<Vec<NestedFieldRef>> {
+        let outer_fields = fields
+            .into_iter()
+            .map(|field| {
+                let new_id = self.resolve_or_assign_id(field.id)?;
+                try_insert_field(&mut self.old_to_new_id, field.id, new_id)?;
+                Ok(Arc::new(Arc::unwrap_or_clone(field).with_id(new_id)))
+            })
+            .collect::<Result<Vec<_>>>()?;
+
+        outer_fields
+            .into_iter()
+            .map(|field| {
+                if !field.field_type.is_nested() {
+                    Ok(field)
+                } else {
+                    let mut field = Arc::unwrap_or_clone(field);
+                    *field.field_type = self.assign_type(*field.field_type)?;
+                    Ok(Arc::new(field))
+                }
+            })
+            .collect()
+    }
+
+    fn assign_type(&mut self, field_type: Type) -> Result<Type> {
+        match field_type {
+            Type::Primitive(primitive) => Ok(Type::Primitive(primitive)),
+            Type::Struct(r#struct) => Ok(Type::Struct(StructType::new(
+                self.assign_fields(r#struct.fields().to_vec())?,
+            ))),
+            Type::List(list) => {
+                let new_id = self.resolve_or_assign_id(list.element_field.id)?;
+                try_insert_field(&mut self.old_to_new_id, 
list.element_field.id, new_id)?;
+                let mut element_field = 
Arc::unwrap_or_clone(list.element_field);
+                element_field.id = new_id;
+                *element_field.field_type = 
self.assign_type(*element_field.field_type)?;
+                Ok(Type::List(ListType {
+                    element_field: Arc::new(element_field),
+                }))
+            }
+            Type::Map(map) => {
+                // Key and value ids are resolved before recursing into 
either, matching Java and
+                // intentionally unlike `ReassignFieldIds`, which recurses the 
key first.
+                let new_key_id = self.resolve_or_assign_id(map.key_field.id)?;
+                let new_value_id = 
self.resolve_or_assign_id(map.value_field.id)?;
+                try_insert_field(&mut self.old_to_new_id, map.key_field.id, 
new_key_id)?;
+                try_insert_field(&mut self.old_to_new_id, map.value_field.id, 
new_value_id)?;
+
+                let mut key_field = Arc::unwrap_or_clone(map.key_field);
+                key_field.id = new_key_id;
+                *key_field.field_type = 
self.assign_type(*key_field.field_type)?;
+
+                let mut value_field = Arc::unwrap_or_clone(map.value_field);
+                value_field.id = new_value_id;
+                *value_field.field_type = 
self.assign_type(*value_field.field_type)?;
+
+                Ok(Type::Map(MapType {
+                    key_field: Arc::new(key_field),
+                    value_field: Arc::new(value_field),
+                }))
+            }
+            Type::Variant(variant) => Ok(Type::Variant(variant)),
+        }
+    }
+
+    fn apply_to_identifier_fields(&self, field_ids: HashSet<i32>) -> 
Result<HashSet<i32>> {
+        field_ids
+            .into_iter()
+            .map(|id| {
+                self.old_to_new_id
+                    .get(&id)
+                    .copied()
+                    .ok_or_else(|| invalid_data!("identifier field id {id} not 
found"))
+            })
+            .collect()
+    }
+
+    fn apply_to_aliases(&self, aliases: BiHashMap<String, i32>) -> 
Result<BiHashMap<String, i32>> {
+        aliases
+            .into_iter()
+            .map(|(name, id)| {
+                self.old_to_new_id
+                    .get(&id)
+                    .copied()
+                    .ok_or_else(|| invalid_data!("Field with id {id} for alias 
{name} not found"))
+                    .map(|new_id| (name, new_id))
+            })
+            .collect()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::spec::{Literal, VariantType};
+
+    fn empty_schema() -> Schema {
+        Schema::builder().build().unwrap()
+    }
+
+    #[test]
+    fn test_assign_fresh_ids() {
+        let schema = Schema::builder()
+            .with_fields(vec![
+                NestedField::required(0, "a", 
Type::Primitive(PrimitiveType::Int)).into(),
+                NestedField::required(1, "c", 
Type::Primitive(PrimitiveType::Int))
+                    .with_initial_default(Literal::int(23))
+                    .with_write_default(Literal::int(34))
+                    .into(),
+                NestedField::required(2, "B", 
Type::Primitive(PrimitiveType::Int)).into(),
+            ])
+            .build()
+            .unwrap();
+        let expected = Schema::builder()
+            .with_fields(vec![
+                NestedField::required(11, "a", 
Type::Primitive(PrimitiveType::Int)).into(),
+                NestedField::required(12, "c", 
Type::Primitive(PrimitiveType::Int))
+                    .with_initial_default(Literal::int(23))
+                    .with_write_default(Literal::int(34))
+                    .into(),
+                NestedField::required(13, "B", 
Type::Primitive(PrimitiveType::Int)).into(),
+            ])
+            .build()
+            .unwrap();
+
+        let assigned = assign_fresh_ids(schema, &empty_schema(), 10).unwrap();
+
+        assert_eq!(assigned.as_struct(), expected.as_struct());
+    }
+
+    #[test]
+    fn test_assign_fresh_ids_with_type() {
+        for test_type in [
+            Type::Primitive(PrimitiveType::Boolean),
+            Type::Primitive(PrimitiveType::Int),
+            Type::Primitive(PrimitiveType::Long),
+            Type::Primitive(PrimitiveType::Float),
+            Type::Primitive(PrimitiveType::Double),
+            Type::Primitive(PrimitiveType::Decimal {
+                precision: 9,
+                scale: 2,
+            }),
+            Type::Primitive(PrimitiveType::Date),
+            Type::Primitive(PrimitiveType::Time),
+            Type::Primitive(PrimitiveType::Timestamp),
+            Type::Primitive(PrimitiveType::Timestamptz),
+            Type::Primitive(PrimitiveType::TimestampNs),
+            Type::Primitive(PrimitiveType::TimestamptzNs),
+            Type::Primitive(PrimitiveType::String),
+            Type::Primitive(PrimitiveType::Uuid),
+            Type::Primitive(PrimitiveType::Fixed(16)),
+            Type::Primitive(PrimitiveType::Binary),
+            Type::Variant(VariantType),
+        ] {
+            let schema = Schema::builder()
+                .with_fields(vec![
+                    NestedField::required(0, "id", 
Type::Primitive(PrimitiveType::Int)).into(),
+                    NestedField::optional(1, "data", test_type.clone()).into(),
+                ])
+                .build()
+                .unwrap();
+            let expected = Schema::builder()
+                .with_fields(vec![
+                    NestedField::required(11, "id", 
Type::Primitive(PrimitiveType::Int)).into(),
+                    NestedField::optional(12, "data", 
test_type.clone()).into(),
+                ])
+                .build()
+                .unwrap();
+
+            let assigned = assign_fresh_ids(schema, &empty_schema(), 
10).unwrap();
+
+            assert_eq!(
+                assigned.as_struct(),
+                expected.as_struct(),
+                "failed for type: {test_type:?}"
+            );
+        }
+    }
+
+    #[test]
+    fn 
test_assign_fresh_ids_reuses_full_names_and_assigns_new_ids_in_correct_order() {
+        let base = Schema::builder()
+            .with_fields(vec![
+                NestedField::required(
+                    1,
+                    "nested",
+                    Type::Struct(StructType::new(vec![
+                        NestedField::optional(2, "a", 
Type::Primitive(PrimitiveType::Int)).into(),
+                    ])),
+                )
+                .into(),
+                NestedField::optional(
+                    3,
+                    "items",
+                    Type::List(ListType::new(
+                        NestedField::list_element(4, 
Type::Primitive(PrimitiveType::String), false)
+                            .into(),
+                    )),
+                )
+                .into(),
+                NestedField::optional(
+                    5,
+                    "properties",
+                    Type::Map(MapType::optional(
+                        6,
+                        Type::Primitive(PrimitiveType::String),
+                        7,
+                        Type::Primitive(PrimitiveType::Long),
+                    )),
+                )
+                .into(),
+                NestedField::required(8, "x", 
Type::Primitive(PrimitiveType::Long)).into(),
+                NestedField::optional(9, "dropped", 
Type::Primitive(PrimitiveType::Int)).into(),
+            ])
+            .build()
+            .unwrap();
+        let replacement = Schema::builder()
+            .with_schema_id(1)
+            .with_identifier_field_ids([18])
+            .with_alias(BiHashMap::from_iter([("a_alias".to_string(), 12)]))
+            .with_fields(vec![
+                NestedField::required(
+                    10,
+                    "nested",
+                    Type::Struct(StructType::new(vec![
+                        NestedField::optional(11, "b", 
Type::Primitive(PrimitiveType::Int)).into(),
+                        NestedField::optional(12, "a", 
Type::Primitive(PrimitiveType::Int)).into(),
+                    ])),
+                )
+                .into(),
+                NestedField::optional(
+                    13,
+                    "items",
+                    Type::List(ListType::new(
+                        NestedField::list_element(
+                            14,
+                            Type::Primitive(PrimitiveType::String),
+                            false,
+                        )
+                        .into(),
+                    )),
+                )
+                .into(),
+                NestedField::optional(
+                    15,
+                    "properties",
+                    Type::Map(MapType::optional(
+                        16,
+                        Type::Primitive(PrimitiveType::String),
+                        17,
+                        Type::Primitive(PrimitiveType::Long),
+                    )),
+                )
+                .into(),
+                NestedField::required(18, "x", 
Type::Primitive(PrimitiveType::Long)).into(),
+                NestedField::optional(19, "z", 
Type::Primitive(PrimitiveType::Int)).into(),
+            ])
+            .build()
+            .unwrap();
+
+        let assigned = assign_fresh_ids(replacement, &base, 9).unwrap();
+
+        assert_eq!(assigned.field_by_name("nested").unwrap().id, 1);
+        assert_eq!(assigned.field_by_name("nested.a").unwrap().id, 2);
+        assert_eq!(assigned.field_by_name("items").unwrap().id, 3);
+        assert_eq!(assigned.field_by_name("items.element").unwrap().id, 4);
+        assert_eq!(assigned.field_by_name("properties").unwrap().id, 5);
+        assert_eq!(assigned.field_by_name("properties.key").unwrap().id, 6);
+        assert_eq!(assigned.field_by_name("properties.value").unwrap().id, 7);
+        assert_eq!(assigned.field_by_name("x").unwrap().id, 8);
+        assert_eq!(assigned.field_by_name("z").unwrap().id, 10);
+        assert_eq!(assigned.field_by_name("nested.b").unwrap().id, 11);
+        assert_eq!(
+            assigned.identifier_field_ids().collect::<HashSet<_>>(),
+            HashSet::from([8])
+        );
+        assert_eq!(assigned.field_by_alias("a_alias").unwrap().id, 2);
+        assert_eq!(assigned.highest_field_id(), 11);
+    }
+
+    /// The table once had ids 1..=5; only 1 and 3 survive in `base`, so 4 and 
5 are retired and the
+    /// correct `last_column_id` is 5, not `base.highest_field_id()`.
+    fn schemas_with_dropped_column_ids() -> (Schema, Schema) {
+        let base = Schema::builder()
+            .with_fields(vec![
+                NestedField::required(1, "a", 
Type::Primitive(PrimitiveType::Int)).into(),
+                NestedField::required(3, "b", 
Type::Primitive(PrimitiveType::Int)).into(),
+            ])
+            .build()
+            .unwrap();
+        let replacement = Schema::builder()
+            .with_fields(vec![
+                NestedField::required(1, "a", 
Type::Primitive(PrimitiveType::Int)).into(),
+                NestedField::optional(2, "fresh", 
Type::Primitive(PrimitiveType::Int)).into(),
+            ])
+            .build()
+            .unwrap();
+        (base, replacement)
+    }
+
+    #[test]
+    fn test_assign_fresh_ids_skips_dropped_column_ids_when_seeded_correctly() {
+        let (base, replacement) = schemas_with_dropped_column_ids();
+
+        let assigned = assign_fresh_ids(replacement, &base, 5).unwrap();
+
+        assert_eq!(assigned.field_by_name("a").unwrap().id, 1);
+        assert_eq!(assigned.field_by_name("fresh").unwrap().id, 6);
+    }
+
+    #[test]
+    fn test_assign_fresh_ids_seeded_too_low_reuses_dropped_column_id() {
+        let (base, replacement) = schemas_with_dropped_column_ids();
+
+        // Passing base's highest id as `last_column_id` clears the guard and 
`build()` sees no
+        // duplicates, so the retired id 4 is reused silently. `base` alone 
cannot reveal that 4 is
+        // retired, so this stays the caller's contract.
+        let assigned = assign_fresh_ids(replacement, &base, 
base.highest_field_id()).unwrap();
+
+        assert_eq!(assigned.field_by_name("fresh").unwrap().id, 4);
+    }
+
+    #[test]
+    fn 
test_assign_fresh_ids_rejects_last_column_id_below_base_highest_field_id() {
+        let (base, replacement) = schemas_with_dropped_column_ids();
+
+        let err = assign_fresh_ids(replacement, &base, base.highest_field_id() 
- 1).unwrap_err();
+
+        assert_eq!(err.kind(), ErrorKind::DataInvalid);
+        assert!(
+            err.message()
+                .contains("last_column_id (2) is below base.highest_field_id() 
(3)")
+        );
+    }
+
+    #[test]
+    fn test_assign_fresh_ids_rejects_alias_for_dropped_field() {
+        // `build()` validates `identifier_field_ids` but not `alias_to_id`, 
so an alias left
+        // pointing at a field the replacement drops reaches 
`apply_to_aliases`.
+        let base = Schema::builder()
+            .with_fields(vec![
+                NestedField::required(1, "a", 
Type::Primitive(PrimitiveType::Int)).into(),
+                NestedField::optional(2, "dropped", 
Type::Primitive(PrimitiveType::Int)).into(),
+            ])
+            .build()
+            .unwrap();
+        let replacement = Schema::builder()
+            .with_alias(BiHashMap::from_iter([("dropped_alias".to_string(), 
2)]))
+            .with_fields(vec![
+                NestedField::required(1, "a", 
Type::Primitive(PrimitiveType::Int)).into(),
+            ])
+            .build()
+            .unwrap();
+
+        let err = assign_fresh_ids(replacement, &base, 2).unwrap_err();
+
+        assert!(
+            err.message()
+                .contains("Field with id 2 for alias dropped_alias not found")
+        );
+    }
+
+    #[test]
+    fn test_assign_fresh_ids_assigns_map_ids_before_nested_types() {
+        let schema = Schema::builder()
+            .with_fields(vec![
+                NestedField::required(
+                    1,
+                    "map",
+                    Type::Map(MapType::required(
+                        2,
+                        Type::Struct(StructType::new(vec![
+                            NestedField::required(
+                                3,
+                                "key_nested",
+                                Type::Primitive(PrimitiveType::Int),
+                            )
+                            .into(),
+                        ])),
+                        4,
+                        Type::Struct(StructType::new(vec![
+                            NestedField::required(
+                                5,
+                                "value_nested",
+                                Type::Primitive(PrimitiveType::Int),
+                            )
+                            .into(),
+                        ])),
+                    )),
+                )
+                .into(),
+                NestedField::required(6, "tail", 
Type::Primitive(PrimitiveType::Int)).into(),
+            ])
+            .build()
+            .unwrap();
+
+        let assigned = assign_fresh_ids(schema, &empty_schema(), 10).unwrap();
+
+        assert_eq!(assigned.field_by_name("map").unwrap().id, 11);
+        assert_eq!(assigned.field_by_name("tail").unwrap().id, 12);
+        assert_eq!(assigned.field_by_name("map.key").unwrap().id, 13);
+        assert_eq!(assigned.field_by_name("map.value").unwrap().id, 14);
+        assert_eq!(assigned.field_by_name("map.key.key_nested").unwrap().id, 
15);
+        assert_eq!(
+            assigned.field_by_name("map.value.value_nested").unwrap().id,
+            16
+        );
+    }
+}
diff --git a/crates/iceberg/src/spec/schema/id_reassigner.rs 
b/crates/iceberg/src/spec/schema/id_reassigner.rs
index 0c9b4de3f..dafaeaa64 100644
--- a/crates/iceberg/src/spec/schema/id_reassigner.rs
+++ b/crates/iceberg/src/spec/schema/id_reassigner.rs
@@ -53,7 +53,7 @@ impl ReassignFieldIds {
         outer_fields
             .into_iter()
             .map(|field| {
-                if field.field_type.is_primitive() {
+                if !field.field_type.is_nested() {
                     Ok(field)
                 } else {
                     let mut new_field = Arc::unwrap_or_clone(field);
@@ -122,7 +122,7 @@ impl ReassignFieldIds {
                 self.old_to_new_id
                     .get(&id)
                     .copied()
-                    .ok_or_else(|| invalid_data!("Identifier Field ID {id} not 
found"))
+                    .ok_or_else(|| invalid_data!("identifier field id {id} not 
found"))
             })
             .collect()
     }
diff --git a/crates/iceberg/src/spec/schema/mod.rs 
b/crates/iceberg/src/spec/schema/mod.rs
index 67924dfe8..d55d0889d 100644
--- a/crates/iceberg/src/spec/schema/mod.rs
+++ b/crates/iceberg/src/spec/schema/mod.rs
@@ -25,6 +25,9 @@ mod utils;
 mod visitor;
 pub use self::visitor::*;
 pub(super) mod _serde;
+// TODO: Use for table replacement in 
https://github.com/apache/iceberg-rust/issues/3056.
+#[allow(dead_code)]
+pub(crate) mod assign_fresh_ids;
 mod id_reassigner;
 mod index;
 mod prune_columns;

Reply via email to