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 7810abe7 feat(datafusion): support alter column (#625)
7810abe7 is described below

commit 7810abe711e72255aeb20d005b457103f3dcb824
Author: QuakeWang <[email protected]>
AuthorDate: Tue Jul 28 23:12:26 2026 +0800

    feat(datafusion): support alter column (#625)
---
 crates/integrations/datafusion/src/sql_context.rs  | 175 ++++++++++++++++++++-
 .../datafusion/tests/sql_context_tests.rs          |  47 ++++++
 crates/paimon/src/spec/schema.rs                   | 132 ++++++++++++++++
 3 files changed, 349 insertions(+), 5 deletions(-)

diff --git a/crates/integrations/datafusion/src/sql_context.rs 
b/crates/integrations/datafusion/src/sql_context.rs
index 3190487b..10532c57 100644
--- a/crates/integrations/datafusion/src/sql_context.rs
+++ b/crates/integrations/datafusion/src/sql_context.rs
@@ -28,6 +28,8 @@
 //! - `ALTER TABLE db.t ADD COLUMN col TYPE`
 //! - `ALTER TABLE db.t DROP COLUMN col`
 //! - `ALTER TABLE db.t RENAME COLUMN old TO new`
+//! - `ALTER TABLE db.t ALTER COLUMN col TYPE new_type`
+//! - `ALTER TABLE db.t ALTER COLUMN col SET|DROP NOT NULL`
 //! - `ALTER TABLE db.t RENAME TO new_name`
 //! - `ALTER TABLE db.t DROP PARTITION (col = val, ...)`
 //! - `CREATE VIEW [IF NOT EXISTS] view [(col, ...)] AS query`
@@ -55,11 +57,11 @@ use datafusion::logical_expr::{Expr as LogicalExpr, 
LogicalPlan, Volatility};
 use datafusion::prelude::{DataFrame, SessionContext};
 use datafusion::sql::planner::IdentNormalizer;
 use datafusion::sql::sqlparser::ast::{
-    AlterTableOperation, BinaryLength, CharacterLength, ColumnDef, 
ColumnOption, CreateFunction,
-    CreateFunctionBody, CreateTable, CreateTableOptions, CreateView, Delete, 
Expr as SqlExpr,
-    FromTable, FunctionBehavior, FunctionReturnType, Insert, Merge, 
ObjectName, ObjectType,
-    RenameTableNameKind, Reset, ResetStatement, Set, ShowCreateObject, 
SqlOption, Statement,
-    TableFactor, TableObject, Truncate, Update, Value as SqlValue,
+    AlterColumnOperation, AlterTableOperation, BinaryLength, CharacterLength, 
ColumnDef,
+    ColumnOption, CreateFunction, CreateFunctionBody, CreateTable, 
CreateTableOptions, CreateView,
+    Delete, Expr as SqlExpr, FromTable, FunctionBehavior, FunctionReturnType, 
Insert, Merge,
+    ObjectName, ObjectType, RenameTableNameKind, Reset, ResetStatement, Set, 
ShowCreateObject,
+    SqlOption, Statement, TableFactor, TableObject, Truncate, Update, Value as 
SqlValue,
 };
 use datafusion::sql::sqlparser::dialect::GenericDialect;
 use datafusion::sql::sqlparser::keywords::Keyword;
@@ -1030,6 +1032,20 @@ impl SQLContext {
         Self::ensure_main_branch_write_target(name, "ALTER TABLE")?;
         let identifier = self.resolve_table_name(name)?;
 
+        if operations.len() > 1
+            && operations.iter().any(|operation| {
+                matches!(
+                    operation,
+                    AlterTableOperation::RenameTable { .. }
+                        | AlterTableOperation::DropPartitions { .. }
+                )
+            })
+        {
+            return Err(DataFusionError::Plan(
+                "ALTER TABLE RENAME TO and DROP PARTITION must be used 
alone".to_string(),
+            ));
+        }
+
         let mut changes = Vec::new();
         let mut rename_to: Option<Identifier> = None;
 
@@ -1052,6 +1068,9 @@ impl SQLContext {
                         new_column_name.value.clone(),
                     ));
                 }
+                AlterTableOperation::AlterColumn { column_name, op } => {
+                    
changes.push(alter_column_to_schema_change(&column_name.value, op)?);
+                }
                 AlterTableOperation::RenameTable { table_name } => {
                     let new_name = match table_name {
                         RenameTableNameKind::To(name) | 
RenameTableNameKind::As(name) => {
@@ -2380,6 +2399,41 @@ fn column_def_to_add_column(col: &ColumnDef) -> 
DFResult<SchemaChange> {
     })
 }
 
+fn alter_column_to_schema_change(
+    column_name: &str,
+    operation: &AlterColumnOperation,
+) -> DFResult<SchemaChange> {
+    match operation {
+        AlterColumnOperation::SetNotNull => 
Ok(SchemaChange::update_column_nullability(
+            column_name.to_string(),
+            false,
+        )),
+        AlterColumnOperation::DropNotNull => 
Ok(SchemaChange::update_column_nullability(
+            column_name.to_string(),
+            true,
+        )),
+        AlterColumnOperation::SetDataType {
+            data_type, using, ..
+        } => {
+            if using.is_some() {
+                return Err(DataFusionError::Plan(
+                    "ALTER COLUMN TYPE USING is not supported".to_string(),
+                ));
+            }
+            let new_data_type = sql_data_type_to_paimon_type(data_type, true)?;
+            Ok(SchemaChange::UpdateColumnType {
+                field_names: vec![column_name.to_string()],
+                new_data_type,
+                // A type-only SQL change must not change the column's 
nullability.
+                keep_nullability: true,
+            })
+        }
+        other => Err(DataFusionError::Plan(format!(
+            "Unsupported ALTER COLUMN operation: {other}"
+        ))),
+    }
+}
+
 fn column_def_to_paimon_type(col: &ColumnDef) -> DFResult<PaimonDataType> {
     sql_data_type_to_paimon_type(&col.data_type, column_def_nullable(col))
 }
@@ -3273,6 +3327,7 @@ mod tests {
 
     use async_trait::async_trait;
     use datafusion::arrow::array::StringViewArray;
+    use datafusion::sql::sqlparser::dialect::PostgreSqlDialect;
     use paimon::catalog::Database;
     use paimon::spec::{
         DataField as PaimonDataField, DataType as PaimonDataType, IntType, 
Schema as PaimonSchema,
@@ -6180,6 +6235,116 @@ mod tests {
         }
     }
 
+    #[tokio::test]
+    async fn test_alter_table_update_column_type_preserves_nullability() {
+        let catalog = Arc::new(MockCatalog::new());
+        let sql_context = make_sql_context(catalog.clone()).await;
+
+        for sql in [
+            "ALTER TABLE mydb.t1 ALTER COLUMN value TYPE BIGINT",
+            "ALTER TABLE mydb.t1 ALTER COLUMN value SET DATA TYPE BIGINT",
+        ] {
+            sql_context.sql(sql).await.unwrap();
+
+            let calls = catalog.take_calls();
+            assert_eq!(calls.len(), 1);
+            if let CatalogCall::AlterTable { changes, .. } = &calls[0] {
+                assert_eq!(changes.len(), 1);
+                assert!(matches!(
+                    &changes[0],
+                    SchemaChange::UpdateColumnType {
+                        field_names,
+                        new_data_type: PaimonDataType::BigInt(_),
+                        keep_nullability: true,
+                    } if field_names.first().map(String::as_str) == 
Some("value")
+                ));
+            } else {
+                panic!("expected AlterTable call");
+            }
+        }
+    }
+
+    #[tokio::test]
+    async fn test_alter_table_update_column_nullability() {
+        let catalog = Arc::new(MockCatalog::new());
+        let sql_context = make_sql_context(catalog.clone()).await;
+
+        for (sql, expected_nullability) in [
+            ("ALTER TABLE mydb.t1 ALTER COLUMN value SET NOT NULL", false),
+            ("ALTER TABLE mydb.t1 ALTER COLUMN value DROP NOT NULL", true),
+        ] {
+            sql_context.sql(sql).await.unwrap();
+
+            let calls = catalog.take_calls();
+            assert_eq!(calls.len(), 1);
+            if let CatalogCall::AlterTable { changes, .. } = &calls[0] {
+                assert_eq!(changes.len(), 1);
+                assert!(matches!(
+                    &changes[0],
+                    SchemaChange::UpdateColumnNullability {
+                        field_names,
+                        new_nullability,
+                    } if field_names.first().map(String::as_str) == 
Some("value")
+                        && *new_nullability == expected_nullability
+                ));
+            } else {
+                panic!("expected AlterTable call");
+            }
+        }
+    }
+
+    #[test]
+    fn test_alter_table_update_column_type_rejects_using() {
+        let statements = Parser::parse_sql(
+            &PostgreSqlDialect {},
+            "ALTER TABLE mydb.t1 ALTER COLUMN value \
+             TYPE BIGINT USING CAST(value AS BIGINT)",
+        )
+        .unwrap();
+        let Statement::AlterTable(alter_table) = &statements[0] else {
+            panic!("expected ALTER TABLE statement");
+        };
+        let AlterTableOperation::AlterColumn { column_name, op } = 
&alter_table.operations[0]
+        else {
+            panic!("expected ALTER COLUMN operation");
+        };
+
+        let err = alter_column_to_schema_change(&column_name.value, 
op).unwrap_err();
+
+        assert!(err.to_string().contains("USING is not supported"));
+    }
+
+    #[tokio::test]
+    async fn test_alter_table_update_column_default_is_unsupported() {
+        let catalog = Arc::new(MockCatalog::new());
+        let sql_context = make_sql_context(catalog.clone()).await;
+
+        let err = sql_context
+            .sql("ALTER TABLE mydb.t1 ALTER COLUMN value SET DEFAULT 1")
+            .await
+            .unwrap_err();
+
+        assert!(err
+            .to_string()
+            .contains("Unsupported ALTER COLUMN operation"));
+        assert!(catalog.take_calls().is_empty());
+    }
+
+    #[tokio::test]
+    async fn 
test_alter_table_rejects_mixed_special_operations_before_catalog_calls() {
+        let catalog = Arc::new(MockCatalog::new());
+        let sql_context = make_sql_context(catalog.clone()).await;
+
+        for sql in [
+            "ALTER TABLE mydb.t1 RENAME TO t2, ADD COLUMN age INT",
+            "ALTER TABLE mydb.t1 DROP PARTITION (pt = 'a'), ADD COLUMN age 
INT",
+        ] {
+            let err = sql_context.sql(sql).await.unwrap_err();
+            assert!(err.to_string().contains("must be used alone"));
+            assert!(catalog.take_calls().is_empty());
+        }
+    }
+
     #[tokio::test]
     async fn test_alter_table_rename_table() {
         let catalog = Arc::new(MockCatalog::new());
diff --git a/crates/integrations/datafusion/tests/sql_context_tests.rs 
b/crates/integrations/datafusion/tests/sql_context_tests.rs
index d0f1c244..f42acbda 100644
--- a/crates/integrations/datafusion/tests/sql_context_tests.rs
+++ b/crates/integrations/datafusion/tests/sql_context_tests.rs
@@ -1163,6 +1163,53 @@ async fn test_alter_table_add_column() {
     assert_eq!(names, vec!["id", "name", "age"]);
 }
 
+#[tokio::test]
+async fn test_alter_table_update_column_type_and_nullability() {
+    let (_tmp, catalog) = create_test_env();
+    let sql_context = create_sql_context(catalog.clone()).await;
+    let identifier = Identifier::new("mydb", "alter_column_test");
+
+    catalog
+        .create_database("mydb", false, Default::default())
+        .await
+        .unwrap();
+    let schema = paimon::spec::Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column("value", DataType::Int(IntType::new()))
+        .option("alter-column-null-to-not-null.disabled", "false")
+        .build()
+        .unwrap();
+    catalog
+        .create_table(&identifier, schema, false)
+        .await
+        .unwrap();
+
+    sql_context
+        .sql("ALTER TABLE mydb.alter_column_test ALTER COLUMN value SET NOT 
NULL")
+        .await
+        .expect("ALTER COLUMN SET NOT NULL should succeed");
+    let table = catalog.get_table(&identifier).await.unwrap();
+    assert!(!table.schema().fields()[1].data_type().is_nullable());
+
+    sql_context
+        .sql("ALTER TABLE mydb.alter_column_test ALTER COLUMN value TYPE 
BIGINT")
+        .await
+        .expect("ALTER COLUMN TYPE should succeed");
+    let table = catalog.get_table(&identifier).await.unwrap();
+    assert!(matches!(
+        table.schema().fields()[1].data_type(),
+        DataType::BigInt(_)
+    ));
+    assert!(!table.schema().fields()[1].data_type().is_nullable());
+
+    sql_context
+        .sql("ALTER TABLE mydb.alter_column_test ALTER COLUMN value DROP NOT 
NULL")
+        .await
+        .expect("ALTER COLUMN DROP NOT NULL should succeed");
+    let table = catalog.get_table(&identifier).await.unwrap();
+    assert!(table.schema().fields()[1].data_type().is_nullable());
+}
+
 #[tokio::test]
 async fn test_alter_table_rename() {
     let (_tmp, catalog) = create_test_env();
diff --git a/crates/paimon/src/spec/schema.rs b/crates/paimon/src/spec/schema.rs
index f96bc0d7..90d74ded 100644
--- a/crates/paimon/src/spec/schema.rs
+++ b/crates/paimon/src/spec/schema.rs
@@ -255,6 +255,7 @@ impl TableSchema {
                             message: format!("Cannot rename partition column: 
[{name}]"),
                         });
                     }
+                    
assert_not_updating_primary_key_index_column(&self.options, name, "rename")?;
                     let idx =
                         field_index(&fields, name).ok_or_else(|| 
crate::Error::ColumnNotExist {
                             full_name: full_name.to_string(),
@@ -302,6 +303,7 @@ impl TableSchema {
                             ),
                         });
                     }
+                    
assert_not_updating_primary_key_index_column(&self.options, name, "drop")?;
                     // Dropping a column referenced by `bucket-key` / 
`sequence.field`
                     // would silently break bucket assignment / sequence 
ordering on
                     // existing data (e.g. `bucket_key_indices` becomes empty 
and writes
@@ -355,6 +357,11 @@ impl TableSchema {
                             message: "Cannot update primary key".to_string(),
                         });
                     }
+                    assert_not_updating_primary_key_index_column(
+                        &self.options,
+                        name,
+                        "update type of",
+                    )?;
                     let idx =
                         field_index(&fields, name).ok_or_else(|| 
crate::Error::ColumnNotExist {
                             full_name: full_name.to_string(),
@@ -568,6 +575,35 @@ fn assert_nullability_change(
     Ok(())
 }
 
+/// Reject destructive changes to columns referenced by a primary-key index.
+///
+/// The index metadata and existing index files are tied to the original column
+/// name and type. Mirrors Java
+/// `SchemaManager.assertNotUpdatingPrimaryKeyIndexColumn`.
+fn assert_not_updating_primary_key_index_column(
+    options: &HashMap<String, String>,
+    field_name: &str,
+    operation: &str,
+) -> crate::Result<()> {
+    let core_options = CoreOptions::new(options);
+    let is_vector_index_column = 
core_options.primary_key_vector_index_enabled()
+        && core_options
+            .primary_key_vector_index_columns()?
+            .iter()
+            .any(|column| column == field_name);
+    let is_full_text_index_column = core_options
+        .primary_key_full_text_index_columns()
+        .iter()
+        .any(|column| column == field_name);
+
+    if is_vector_index_column || is_full_text_index_column {
+        return Err(crate::Error::Unsupported {
+            message: format!("Cannot {operation} primary-key index column: 
[{field_name}]"),
+        });
+    }
+    Ok(())
+}
+
 /// Rename a key in a partition/primary key list, if present.
 fn rename_in_keys(keys: &mut [String], old: &str, new: &str) {
     for key in keys.iter_mut() {
@@ -2981,6 +3017,102 @@ mod tests {
         );
     }
 
+    fn assert_primary_key_index_column_changes_rejected(
+        table_schema: &TableSchema,
+        column_name: &str,
+        new_data_type: DataType,
+    ) {
+        let changes = [
+            (
+                crate::spec::SchemaChange::rename_column(
+                    column_name.to_string(),
+                    format!("renamed_{column_name}"),
+                ),
+                format!("Cannot rename primary-key index column: 
[{column_name}]"),
+            ),
+            (
+                
crate::spec::SchemaChange::drop_column(column_name.to_string()),
+                format!("Cannot drop primary-key index column: 
[{column_name}]"),
+            ),
+            (
+                crate::spec::SchemaChange::update_column_type(
+                    column_name.to_string(),
+                    new_data_type,
+                ),
+                format!("Cannot update type of primary-key index column: 
[{column_name}]"),
+            ),
+        ];
+
+        for (change, expected_message) in changes {
+            let err = table_schema.apply_changes(vec![change]).unwrap_err();
+            assert!(
+                matches!(err, crate::Error::Unsupported { ref message }
+                    if message == &expected_message),
+                "expected primary-key index guard, got {err:?}"
+            );
+        }
+    }
+
+    #[test]
+    fn test_rejects_destructive_primary_key_full_text_index_column_changes() {
+        let table_schema = TableSchema::new(
+            0,
+            &Schema::builder()
+                .column("id", DataType::Int(IntType::new()))
+                .column("content", 
DataType::VarChar(VarCharType::string_type()))
+                .primary_key(["id"])
+                .option("bucket", "1")
+                .option("deletion-vectors.enabled", "true")
+                .option("pk-full-text.index.columns", "content")
+                .build()
+                .unwrap(),
+        );
+
+        assert_primary_key_index_column_changes_rejected(
+            &table_schema,
+            "content",
+            DataType::Int(IntType::new()),
+        );
+
+        let err = table_schema
+            .apply_changes(vec![
+                
crate::spec::SchemaChange::remove_option("pk-full-text.index.columns".to_string()),
+                crate::spec::SchemaChange::rename_column(
+                    "content".to_string(),
+                    "renamed_content".to_string(),
+                ),
+            ])
+            .unwrap_err();
+        assert!(matches!(
+            err,
+            crate::Error::Unsupported { ref message }
+                if message == "Cannot rename primary-key index column: 
[content]"
+        ));
+    }
+
+    #[test]
+    fn test_rejects_destructive_primary_key_vector_index_column_changes() {
+        let vector_type = DataType::Vector(
+            VectorType::try_new(true, 3, 
DataType::Float(FloatType::new())).unwrap(),
+        );
+        let table_schema = TableSchema::new(
+            0,
+            &Schema::builder()
+                .column("id", DataType::Int(IntType::new()))
+                .column("embedding", vector_type.clone())
+                .primary_key(["id"])
+                .option("bucket", "1")
+                .option("deletion-vectors.enabled", "true")
+                .option("pk-vector.index.columns", "embedding")
+                .option("fields.embedding.pk-vector.index.type", "ivf_flat")
+                .option("fields.embedding.pk-vector.distance.metric", "l2")
+                .build()
+                .unwrap(),
+        );
+
+        assert_primary_key_index_column_changes_rejected(&table_schema, 
"embedding", vector_type);
+    }
+
     #[test]
     fn test_create_schema_rejects_unknown_bucket_key() {
         let err = Schema::builder()

Reply via email to