From 745b397531ebbe52b603f25ee10abba1a7c8d43e Mon Sep 17 00:00:00 2001 From: zgq Date: Wed, 29 Jul 2026 23:31:38 +0800 Subject: [PATCH] fix(mysql): preserve editor alter semantics --- apps/chat2db-web/src/legacy.rs | 188 +++++++++++++++++- .../tests/native_mysql_editable_ddl_docker.rs | 66 ++++++ 2 files changed, 252 insertions(+), 2 deletions(-) diff --git a/apps/chat2db-web/src/legacy.rs b/apps/chat2db-web/src/legacy.rs index ae76b20..023012b 100644 --- a/apps/chat2db-web/src/legacy.rs +++ b/apps/chat2db-web/src/legacy.rs @@ -4805,6 +4805,7 @@ fn mysql_table_alter( } let mut indexes = Vec::new(); + let has_explicit_primary_key_change = has_explicit_primary_key_change(new_table); for index in &new_table.index_list { let status = index .edit_status @@ -4836,6 +4837,11 @@ fn mysql_table_alter( _ => {} } } + if !has_explicit_primary_key_change + && let Some(primary_key_change) = inferred_primary_key_change(old_table, new_table)? + { + indexes.push(primary_key_change); + } Ok(MysqlTableAlter { table, rename_to, @@ -4853,14 +4859,96 @@ fn mysql_table_alter( }) } +fn has_explicit_primary_key_change(table: &LegacyEditableTable) -> bool { + table.index_list.iter().any(|index| { + mysql_index_kind(index) == MysqlIndexKind::Primary + && matches!( + index + .edit_status + .as_deref() + .unwrap_or_default() + .trim() + .to_ascii_uppercase() + .as_str(), + "ADD" | "MODIFY" | "DELETE" + ) + }) +} + +fn inferred_primary_key_change( + old_table: &LegacyEditableTable, + new_table: &LegacyEditableTable, +) -> LegacyResult> { + let old_columns = ordered_primary_key_columns(old_table); + let new_columns = ordered_primary_key_columns(new_table); + let unchanged = old_columns.len() == new_columns.len() + && old_columns + .iter() + .zip(&new_columns) + .all(|(old, new)| old.name.eq_ignore_ascii_case(&new.name)); + if unchanged { + return Ok(None); + } + + let new_index = || -> LegacyResult { + Ok(MysqlIndexDefinition { + kind: MysqlIndexKind::Primary, + name: None, + columns: new_columns + .iter() + .map(|column| { + Ok(MysqlIndexColumn { + name: required_name(&column.name, "columnList.name")?, + prefix_length: None, + order: None, + }) + }) + .collect::>>()?, + method: Some(MysqlIndexMethod::Btree), + comment: None, + }) + }; + + match (old_columns.is_empty(), new_columns.is_empty()) { + (true, false) => Ok(Some(MysqlIndexAlter::Add { + index: new_index()?, + })), + (false, true) => Ok(Some(MysqlIndexAlter::Delete { + kind: MysqlIndexKind::Primary, + name: None, + })), + (false, false) => Ok(Some(MysqlIndexAlter::Modify { + old_kind: MysqlIndexKind::Primary, + old_name: None, + index: new_index()?, + })), + (true, true) => Ok(None), + } +} + +fn ordered_primary_key_columns(table: &LegacyEditableTable) -> Vec<&LegacyColumn> { + let mut columns = table + .column_list + .iter() + .filter(|column| { + column.primary_key == Some(true) + && !has_edit_status(column.edit_status.as_deref(), "DELETE") + }) + .collect::>(); + columns.sort_by_key(|column| column.primary_key_order); + columns +} + fn mysql_column_definition(column: &LegacyColumn) -> LegacyResult { let type_name = required_name(&column.column_type, "columnList.columnType")?; + let (length, scale) = + mysql_column_dimensions(&type_name, column.column_size, column.decimal_digits); Ok(MysqlColumnDefinition { name: required_name(&column.name, "columnList.name")?, unsigned: type_name.to_ascii_uppercase().contains("UNSIGNED"), type_name, - length: positive_u32(column.column_size), - scale: positive_or_zero_u32(column.decimal_digits), + length, + scale, nullable: column.nullable.unwrap_or(1) != 0, default_value: column.default_value.clone(), auto_increment: column.auto_increment.unwrap_or(false), @@ -4872,6 +4960,27 @@ fn mysql_column_definition(column: &LegacyColumn) -> LegacyResult, + decimal_digits: Option, +) -> (Option, Option) { + let base_type = type_name + .split_whitespace() + .next() + .unwrap_or_default() + .to_ascii_uppercase(); + match base_type.as_str() { + "CHAR" | "VARCHAR" | "BINARY" | "VARBINARY" | "BIT" => (positive_u32(column_size), None), + "DECIMAL" | "NUMERIC" => ( + positive_u32(column_size), + positive_or_zero_u32(decimal_digits), + ), + "DATETIME" | "TIMESTAMP" | "TIME" => (positive_or_zero_u32(decimal_digits), None), + _ => (None, None), + } +} + fn mysql_index_definition(index: &LegacyIndex) -> LegacyResult { let kind = mysql_index_kind(index); let columns = index @@ -6649,6 +6758,81 @@ mod tests { assert_eq!(error.code, "mysql_partial_large_value_rejected"); } + #[test] + fn table_alter_derives_primary_key_changes_from_editor_columns() { + let old_table = LegacyEditableTable { + name: "items".to_owned(), + database_name: "inventory".to_owned(), + column_list: vec![ + LegacyColumn { + name: "id".to_owned(), + column_type: "BIGINT".to_owned(), + primary_key: Some(true), + primary_key_order: 1, + nullable: Some(0), + ..LegacyColumn::default() + }, + LegacyColumn { + name: "code".to_owned(), + column_type: "VARCHAR".to_owned(), + column_size: Some(64), + nullable: Some(0), + ..LegacyColumn::default() + }, + ], + index_list: vec![LegacyIndex { + name: "PRIMARY".to_owned(), + index_type: "Primary".to_owned(), + column_list: vec![LegacyIndexColumn { + column_name: "id".to_owned(), + ..LegacyIndexColumn::default() + }], + ..LegacyIndex::default() + }], + ..LegacyEditableTable::default() + }; + let mut new_table = old_table.clone(); + new_table.column_list[0].primary_key = Some(false); + new_table.column_list[0].primary_key_order = 0; + new_table.column_list[0].edit_status = Some("MODIFY".to_owned()); + new_table.column_list[1].primary_key = Some(true); + new_table.column_list[1].primary_key_order = 1; + new_table.column_list[1].edit_status = Some("MODIFY".to_owned()); + + let alter = mysql_table_alter(&old_table, &new_table, "inventory", "") + .expect("column primary-key edits must normalize"); + let sql = build_mysql_alter_table(&alter).expect("primary-key ALTER must build"); + + assert_eq!(sql.matches("DROP PRIMARY KEY").count(), 1); + assert_eq!( + sql.matches("ADD PRIMARY KEY (`code`) USING BTREE").count(), + 1 + ); + } + + #[test] + fn table_editor_metadata_keeps_only_type_appropriate_dimensions() { + let definition = |column_type: &str, column_size, decimal_digits| { + mysql_column_definition(&LegacyColumn { + name: "value".to_owned(), + column_type: column_type.to_owned(), + column_size, + decimal_digits, + ..LegacyColumn::default() + }) + .expect("metadata column must normalize") + }; + + let varchar = definition("VARCHAR", Some(128), Some(0)); + assert_eq!((varchar.length, varchar.scale), (Some(128), None)); + let text = definition("TEXT", Some(65_535), Some(0)); + assert_eq!((text.length, text.scale), (None, None)); + let decimal = definition("DECIMAL", Some(12), Some(3)); + assert_eq!((decimal.length, decimal.scale), (Some(12), Some(3))); + let timestamp = definition("TIMESTAMP", Some(26), Some(6)); + assert_eq!((timestamp.length, timestamp.scale), (Some(6), None)); + } + #[tokio::test] async fn table_and_view_editor_payloads_map_to_core_builders() { let table_request: LegacyTableModifyRequest = serde_json::from_value(serde_json::json!({ diff --git a/apps/chat2db-web/tests/native_mysql_editable_ddl_docker.rs b/apps/chat2db-web/tests/native_mysql_editable_ddl_docker.rs index 77295b9..9988fc1 100644 --- a/apps/chat2db-web/tests/native_mysql_editable_ddl_docker.rs +++ b/apps/chat2db-web/tests/native_mysql_editable_ddl_docker.rs @@ -253,6 +253,48 @@ async fn verify_product_vertical(config: &MysqlTestConfig, database_name: &str) ) .await; assert!(column_exists(config, database_name, "items", "note").await); + + let old_table = get( + &router, + &format!( + "/api/rdb/table/query?dataSourceId={datasource}&databaseType=MYSQL&databaseName={database_name}&tableName=items" + ), + ) + .await; + let mut new_table = old_table.clone(); + let label = new_table["columnList"] + .as_array_mut() + .expect("table columns") + .iter_mut() + .find(|column| column["name"] == "label") + .expect("label column"); + label["primaryKey"] = json!(true); + label["primaryKeyOrder"] = json!(2); + label["editStatus"] = json!("MODIFY"); + let primary_key_sql = post( + &router, + "/api/rdb/table/modify/sql", + json!({ + "dataSourceId": datasource, + "databaseType": "MYSQL", + "databaseName": database_name, + "oldTable": old_table, + "newTable": new_table + }), + ) + .await; + execute_ddl( + &router, + &datasource, + database_name, + "items", + primary_key_sql[0]["sql"].as_str().expect("primary key SQL"), + ) + .await; + assert_eq!( + primary_key_columns(config, database_name, "items").await, + ["id", "label"] + ); assert_java_dormant(&application); let empty_preview = preview(&router, &datasource, database_name, "items").await; @@ -262,6 +304,7 @@ async fn verify_product_vertical(config: &MysqlTestConfig, database_name: &str) "CHAT2DB_ROW_NUMBER" ); assert_eq!(empty_preview["headerList"][1]["primaryKey"], true); + assert_eq!(empty_preview["headerList"][2]["primaryKey"], true); assert_eq!(empty_preview["headerList"][1]["autoIncrement"], 1); let headers = empty_preview["headerList"].clone(); @@ -758,6 +801,29 @@ async fn column_exists( count > 0 } +async fn primary_key_columns( + config: &MysqlTestConfig, + database_name: &str, + table_name: &str, +) -> Vec { + let mut conn = Conn::new(config.options()) + .await + .expect("primary-key probe must connect"); + let columns = conn + .exec::( + "SELECT COLUMN_NAME FROM information_schema.STATISTICS \ + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND INDEX_NAME = 'PRIMARY' \ + ORDER BY SEQ_IN_INDEX", + (database_name, table_name), + ) + .await + .expect("primary-key probe must execute"); + conn.disconnect() + .await + .expect("primary-key probe must close"); + columns +} + async fn object_exists(config: &MysqlTestConfig, database_name: &str, object_name: &str) -> bool { let mut conn = Conn::new(config.options()) .await