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 c7d0dd4b fix(spec): reject reserved system field names at create and 
alter time (#641)
c7d0dd4b is described below

commit c7d0dd4b9a2620c0420332fa21e725e3a26b0449
Author: jackylee <[email protected]>
AuthorDate: Mon Aug 3 16:39:55 2026 +0800

    fix(spec): reject reserved system field names at create and alter time 
(#641)
---
 crates/paimon/src/spec/schema.rs                 | 146 ++++++++++++++++++++---
 crates/paimon/src/table/hybrid_search_builder.rs |  41 ++++---
 2 files changed, 153 insertions(+), 34 deletions(-)

diff --git a/crates/paimon/src/spec/schema.rs b/crates/paimon/src/spec/schema.rs
index 3e2e024b..cc3bb46d 100644
--- a/crates/paimon/src/spec/schema.rs
+++ b/crates/paimon/src/spec/schema.rs
@@ -203,31 +203,17 @@ impl TableSchema {
     /// colliding with a system field (e.g. `_ROW_ID`) is otherwise excluded
     /// from the physical read and silently filled with the system value.
     fn validate_no_reserved_fields(&self) -> crate::Result<()> {
-        // Java SpecialFields.SYSTEM_FIELD_NAMES.
-        const SYSTEM_FIELD_NAMES: [&str; 5] = [
-            SEQUENCE_NUMBER_FIELD_NAME,
-            VALUE_KIND_FIELD_NAME,
-            "_LEVEL",
-            ROW_KIND_FIELD_NAME,
-            ROW_ID_FIELD_NAME,
-        ];
-        const KEY_FIELD_PREFIX: &str = "_KEY_";
         // Java SpecialFields.SYSTEM_FIELD_ID_START = Integer.MAX_VALUE / 2.
         const SYSTEM_FIELD_ID_START: i32 = i32::MAX / 2;
 
+        validate_no_reserved_field_names(&self.fields)?;
+
         for field in &self.fields {
-            let name = field.name();
-            if name.starts_with(KEY_FIELD_PREFIX) || 
SYSTEM_FIELD_NAMES.contains(&name) {
-                return Err(crate::Error::ConfigInvalid {
-                    message: format!(
-                        "Field name '{name}' is reserved for system use and 
cannot be used in a table schema"
-                    ),
-                });
-            }
             if field.id() >= SYSTEM_FIELD_ID_START {
                 return Err(crate::Error::DataInvalid {
                     message: format!(
-                        "Field '{name}' uses reserved system field id {}",
+                        "Field '{}' uses reserved system field id {}",
+                        field.name(),
                         field.id()
                     ),
                     source: None,
@@ -563,6 +549,7 @@ impl TableSchema {
 
         // Re-run create-time validations on the final schema, mirroring Java
         // `SchemaValidation.validateTableSchema` after applying changes.
+        validate_no_reserved_field_names(&new_schema.fields)?;
         Schema::validate_key_field_types(
             &new_schema.fields,
             &new_schema.primary_keys,
@@ -626,6 +613,35 @@ impl TableSchema {
     }
 }
 
+/// Reject column names reserved for system use, mirroring Java 
`SpecialFields`:
+/// the five `SYSTEM_FIELD_NAMES` and the `_KEY_` key-field prefix.
+///
+/// A user column colliding with a system field is otherwise excluded from the
+/// physical read and silently filled with the system value.
+fn validate_no_reserved_field_names(fields: &[DataField]) -> crate::Result<()> 
{
+    // Java SpecialFields.SYSTEM_FIELD_NAMES.
+    const SYSTEM_FIELD_NAMES: [&str; 5] = [
+        SEQUENCE_NUMBER_FIELD_NAME,
+        VALUE_KIND_FIELD_NAME,
+        "_LEVEL",
+        ROW_KIND_FIELD_NAME,
+        ROW_ID_FIELD_NAME,
+    ];
+    const KEY_FIELD_PREFIX: &str = "_KEY_";
+
+    for field in fields {
+        let name = field.name();
+        if name.starts_with(KEY_FIELD_PREFIX) || 
SYSTEM_FIELD_NAMES.contains(&name) {
+            return Err(crate::Error::ConfigInvalid {
+                message: format!(
+                    "Field name '{name}' is reserved for system use and cannot 
be used in a table schema"
+                ),
+            });
+        }
+    }
+    Ok(())
+}
+
 /// Extract the single top-level column name from a `field_names` path.
 ///
 /// Nested struct field paths (length > 1) are not yet supported.
@@ -1085,6 +1101,7 @@ impl Schema {
         let partition_keys = Self::normalize_partition_keys(&partition_keys, 
&mut options)?;
         Self::normalize_blob_comment_directives(&mut fields, &mut options)?;
         let fields = Self::normalize_fields(&fields, &partition_keys, 
&primary_keys, &options)?;
+        validate_no_reserved_field_names(&fields)?;
         Self::validate_key_field_types(&fields, &primary_keys, &options)?;
         Self::validate_blob_fields(&fields, &partition_keys, &options)?;
         Self::validate_vector_store_fields(&fields, &partition_keys, 
&options)?;
@@ -3579,6 +3596,99 @@ mod tests {
         assert_primary_key_index_column_changes_rejected(&table_schema, 
"embedding", vector_type);
     }
 
+    #[test]
+    fn test_create_schema_rejects_reserved_field_names() {
+        // Java `SpecialFields.SYSTEM_FIELD_NAMES` plus the `_KEY_` prefix. A
+        // user column with one of these names is excluded from the physical
+        // read and silently filled with the system value.
+        for name in [
+            "_SEQUENCE_NUMBER",
+            "_VALUE_KIND",
+            "_LEVEL",
+            "rowkind",
+            "_ROW_ID",
+            "_KEY_id",
+        ] {
+            let err = Schema::builder()
+                .column("id", DataType::Int(IntType::new()))
+                .column(name, DataType::Int(IntType::new()))
+                .build()
+                .unwrap_err();
+
+            assert!(
+                matches!(err, crate::Error::ConfigInvalid { ref message }
+                    if message.contains(name) && message.contains("reserved")),
+                "reserved field name '{name}' should be rejected at create 
time, got {err:?}"
+            );
+        }
+    }
+
+    #[test]
+    fn test_create_schema_accepts_names_that_only_look_reserved() {
+        // Guard against over-rejecting: only exact system names and the
+        // `_KEY_` prefix are reserved.
+        let schema = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("_sequence_number", DataType::Int(IntType::new()))
+            .column("row_kind", DataType::Int(IntType::new()))
+            .column("_KEY", DataType::Int(IntType::new()))
+            .build();
+
+        assert!(
+            schema.is_ok(),
+            "names that merely resemble system fields should be accepted, got 
{schema:?}"
+        );
+    }
+
+    #[test]
+    fn test_alter_add_reserved_field_name_rejected() {
+        let table_schema = TableSchema::new(
+            0,
+            &Schema::builder()
+                .column("id", DataType::Int(IntType::new()))
+                .build()
+                .unwrap(),
+        );
+
+        let err = table_schema
+            .apply_changes(vec![crate::spec::SchemaChange::add_column(
+                "_ROW_ID".to_string(),
+                DataType::Int(IntType::new()),
+            )])
+            .unwrap_err();
+
+        assert!(
+            matches!(err, crate::Error::ConfigInvalid { ref message }
+                if message.contains("_ROW_ID") && 
message.contains("reserved")),
+            "adding a reserved column name should be rejected, got {err:?}"
+        );
+    }
+
+    #[test]
+    fn test_alter_rename_to_reserved_field_name_rejected() {
+        let table_schema = TableSchema::new(
+            0,
+            &Schema::builder()
+                .column("id", DataType::Int(IntType::new()))
+                .column("v", DataType::Int(IntType::new()))
+                .build()
+                .unwrap(),
+        );
+
+        let err = table_schema
+            .apply_changes(vec![crate::spec::SchemaChange::rename_column(
+                "v".to_string(),
+                "_VALUE_KIND".to_string(),
+            )])
+            .unwrap_err();
+
+        assert!(
+            matches!(err, crate::Error::ConfigInvalid { ref message }
+                if message.contains("_VALUE_KIND") && 
message.contains("reserved")),
+            "renaming a column to a reserved name should be rejected, got 
{err:?}"
+        );
+    }
+
     #[test]
     fn test_create_schema_rejects_unknown_bucket_key() {
         let err = Schema::builder()
diff --git a/crates/paimon/src/table/hybrid_search_builder.rs 
b/crates/paimon/src/table/hybrid_search_builder.rs
index ab290820..fef41aa7 100644
--- a/crates/paimon/src/table/hybrid_search_builder.rs
+++ b/crates/paimon/src/table/hybrid_search_builder.rs
@@ -1638,24 +1638,33 @@ mod pk_hybrid_tests {
     /// A primary-key hybrid table whose user schema carries an extra column 
named
     /// `reserved`, used to prove the materialized read rejects a reserved 
metadata
     /// name arriving via the default (all-columns) projection.
+    ///
+    /// Deserialized from JSON rather than built through `Schema::builder`, 
because
+    /// `Schema::new` rejects reserved system field names at create time. The 
read
+    /// guard exists for exactly this case: metadata written by another engine.
     fn pk_hybrid_table_with_reserved_column(reserved: &str) -> Table {
         let file_io = FileIOBuilder::new("memory").build().unwrap();
-        let mut builder = Schema::builder()
-            .column("id", DataType::Int(IntType::new()))
-            .column(
-                VECTOR_COLUMN,
-                DataType::Vector(
-                    VectorType::try_new(true, DIM as u32, 
DataType::Float(FloatType::new()))
-                        .unwrap(),
-                ),
-            )
-            .column(TEXT_COLUMN, DataType::VarChar(VarCharType::string_type()))
-            .column(reserved, DataType::VarChar(VarCharType::string_type()))
-            .primary_key(["id"]);
-        for (k, v) in table_options() {
-            builder = builder.option(k, v);
-        }
-        let schema = TableSchema::new(0, &builder.build().unwrap());
+        let options: HashMap<String, String> = 
table_options().into_iter().collect();
+        let schema: TableSchema = serde_json::from_value(serde_json::json!({
+            "version": TableSchema::CURRENT_VERSION,
+            "id": 0,
+            "fields": [
+                {"id": 0, "name": "id", "type": "INT NOT NULL"},
+                {
+                    "id": 1,
+                    "name": VECTOR_COLUMN,
+                    "type": {"type": "VECTOR", "element": "FLOAT", "length": 
DIM},
+                },
+                {"id": 2, "name": TEXT_COLUMN, "type": "STRING"},
+                {"id": 3, "name": reserved, "type": "STRING"},
+            ],
+            "highestFieldId": 3,
+            "partitionKeys": [],
+            "primaryKeys": ["id"],
+            "options": options,
+            "timeMillis": 0,
+        }))
+        .expect("reserved-column schema should deserialize");
         Table::new(
             file_io,
             Identifier::new("default", "pk_hybrid_reserved"),

Reply via email to