fix(mysql): preserve generated columns in table editor

This commit is contained in:
t8y2 committed 2026-08-19 17:25:21 +08:00
1 parent 1315b25c3e
commit bfbbc2bff2
7 files changed
+481 -7

No files matched your search

@@ -1,4 +1,7 @@
use super::column_format::{clickhouse_column_type, column_data_type, column_definition, is_mysql_character_data_type};
use super::column_format::{
clickhouse_column_type, column_data_type, column_definition, is_mysql_character_data_type,
original_is_mysql_generated_column, original_mysql_generated_clause,
};
use super::columns::build_drop_column_sql;
use super::comments::build_sqlserver_column_comment_sql;
use super::dialect::{capabilities_for, database_label, StructureDialect};
@@ -79,6 +82,16 @@ pub fn build_single_column_alter_sql(options: SingleColumnAlterSqlOptions) -> Ta
{
return TableStructureSqlResult { statements, warnings };
}
if dialect == StructureDialect::Mysql
&& original_is_mysql_generated_column(&options.column)
&& original_mysql_generated_clause(&options.column).is_none()
{
warnings.push(format!(
"Column \"{}\" is generated, but its generation expression could not be loaded; no ALTER statement was generated to avoid removing the generated-column definition.",
original.name
));
return TableStructureSqlResult { statements, warnings };
}
if !has_rename && !has_attribute_change && !has_column_extra_change(&options.column) {
return TableStructureSqlResult { statements, warnings };
}
@@ -130,6 +143,14 @@ fn is_column_extra_empty(extra: &ColumnExtra) -> bool {
fn original_manticore_extra_flags(extra: &str) -> (bool, bool, bool, bool) {
let lower = extra.to_lowercase();
let mut tokens = lower.split_whitespace();
let first = tokens.next();
let is_generated_column = first.is_some_and(|token| token == "generated")
|| matches!(first, Some("virtual" | "stored" | "persistent"))
&& tokens.next().is_some_and(|token| token == "generated");
if is_generated_column {
return (false, false, false, false);
}
(
lower.split_whitespace().any(|token| token == "indexed"),
lower.split_whitespace().any(|token| token == "stored"),
@@ -19,19 +19,28 @@ pub(super) fn column_definition(dialect: StructureDialect, column: &EditableStru
parts.push(format!("COLLATE {}", quote_ident(dialect, &column.collation)));
}
}
let mysql_generated_clause =
(dialect == StructureDialect::Mysql).then(|| original_mysql_generated_clause(column)).flatten();
if let Some(generated_clause) = mysql_generated_clause.as_ref() {
parts.push(generated_clause.clone());
}
if !column.is_nullable && !is_oracle_like(dialect) && dialect != StructureDialect::ClickHouse {
parts.push("NOT NULL".to_string());
}
if let Some(extra_clause) = column_extra_clause(dialect, column) {
parts.push(extra_clause);
if mysql_generated_clause.is_none() {
if let Some(extra_clause) = column_extra_clause(dialect, column) {
parts.push(extra_clause);
}
}
let default_value = normalize_default(Some(&column.default_value));
if !default_value.is_empty() {
if mysql_generated_clause.is_none() && !default_value.is_empty() {
parts.push(format!("DEFAULT {}", format_default_for_sql(dialect, &column.data_type, &default_value)));
}
if let Some(on_update) = column.extra.as_ref().and_then(|e| e.on_update_current_timestamp).filter(|v| *v) {
if on_update && dialect == StructureDialect::Mysql {
parts.push("ON UPDATE CURRENT_TIMESTAMP".to_string());
if mysql_generated_clause.is_none() {
if let Some(on_update) = column.extra.as_ref().and_then(|e| e.on_update_current_timestamp).filter(|v| *v) {
if on_update && dialect == StructureDialect::Mysql {
parts.push("ON UPDATE CURRENT_TIMESTAMP".to_string());
}
}
}
if matches!(dialect, StructureDialect::Mysql | StructureDialect::Doris) && !clean(&column.comment).is_empty() {
@@ -40,6 +49,25 @@ pub(super) fn column_definition(dialect: StructureDialect, column: &EditableStru
parts.join(" ")
}
pub(super) fn original_mysql_generated_clause(column: &EditableStructureColumn) -> Option<String> {
let extra = column.original.as_ref()?.extra.as_deref()?.trim();
let normalized = extra.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
(normalized.starts_with("generated ") && normalized.contains(" as (")).then(|| extra.to_string())
}
pub(super) fn original_is_mysql_generated_column(column: &EditableStructureColumn) -> bool {
let Some(extra) = column.original.as_ref().and_then(|original| original.extra.as_deref()) else {
return false;
};
let mut tokens = extra.split_whitespace();
let first = tokens.next();
first.is_some_and(|token| token.eq_ignore_ascii_case("generated"))
|| matches!(first, Some(token) if token.eq_ignore_ascii_case("virtual")
|| token.eq_ignore_ascii_case("stored")
|| token.eq_ignore_ascii_case("persistent"))
&& tokens.next().is_some_and(|token| token.eq_ignore_ascii_case("generated"))
}
pub(super) fn column_extra_clause(dialect: StructureDialect, column: &EditableStructureColumn) -> Option<String> {
let extra = column.extra.as_ref()?;
match dialect {
@@ -7,6 +7,7 @@ use super::column_alter::{
};
use super::column_format::{
column_definition, has_dameng_identity, is_dameng_identity_compatible_type, is_mysql_character_data_type,
original_is_mysql_generated_column, original_mysql_generated_clause,
};
use super::comments::build_sqlserver_column_comment_sql;
use super::dialect::{capabilities_for, database_label, is_oracle_like, StructureDialect};
@@ -165,6 +166,16 @@ pub(super) fn build_column_sql(options: &TableStructureSqlOptions, warnings: &mu
{
continue;
}
if dialect == StructureDialect::Mysql
&& original_is_mysql_generated_column(column)
&& original_mysql_generated_clause(column).is_none()
{
warnings.push(format!(
"Column \"{}\" is generated, but its generation expression could not be loaded; no ALTER statement was generated to avoid removing the generated-column definition.",
original.name
));
continue;
}
if !has_rename && !has_attribute_change && !has_position_change {
continue;
}
@@ -3838,6 +3838,34 @@ fn mysql_single_column_alter_quotes_datetime_literal() {
assert!(result.statements.iter().any(|s| s.contains("DEFAULT '2024-01-01 00:00:00'")));
}
#[test]
fn mysql_single_generated_column_change_is_blocked_without_expression_metadata() {
let mut generated = column("total");
generated.data_type = "decimal(14,2)".to_string();
generated.extra = Some(ColumnExtra::default());
generated.original = Some(ColumnInfo {
name: "total".to_string(),
data_type: "decimal(12,2)".to_string(),
is_nullable: true,
column_default: None,
is_primary_key: false,
extra: Some("STORED GENERATED".to_string()),
comment: None,
..Default::default()
});
let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions {
database_type: Some(DatabaseType::Mysql),
schema: None,
table_name: "products".to_string(),
column: generated,
});
assert!(result.statements.is_empty());
assert_eq!(result.warnings.len(), 1);
assert!(result.warnings[0].contains("generation expression could not be loaded"));
}
#[test]
fn builds_mysql_foreign_key_changes() {
let mut existing = foreign_key("fk_orders_users", "user_id", "users", "id");
@@ -4582,6 +4610,116 @@ fn mysql_character_column_preserves_charset_collation_on_other_change() {
);
}
#[test]
fn mysql_generated_column_preserves_expression_when_modified() {
let mut generated = column("total");
generated.data_type = "decimal(14,2)".to_string();
generated.is_nullable = false;
generated.comment = "Computed total".to_string();
generated.extra = Some(ColumnExtra::default());
generated.original = Some(ColumnInfo {
name: "total".to_string(),
data_type: "decimal(12,2)".to_string(),
is_nullable: true,
column_default: None,
is_primary_key: false,
extra: Some("GENERATED ALWAYS AS (`price` * `quantity`) STORED".to_string()),
comment: None,
..Default::default()
});
generated.original_position = Some(0);
let result = build_table_structure_change_sql(structure_change_options(
DatabaseType::Mysql,
None,
"products",
vec![generated],
));
assert_eq!(result.warnings, Vec::<String>::new());
assert_eq!(
result.statements,
vec![
"ALTER TABLE `products` MODIFY COLUMN `total` decimal(14,2) GENERATED ALWAYS AS (`price` * `quantity`) STORED NOT NULL COMMENT 'Computed total';"
]
);
}
#[test]
fn mysql_unchanged_generated_column_is_not_modified_with_other_columns() {
let mut generated = column("total");
generated.data_type = "decimal(12,2)".to_string();
generated.extra = Some(ColumnExtra::default());
generated.original = Some(ColumnInfo {
name: "total".to_string(),
data_type: "decimal(12,2)".to_string(),
is_nullable: true,
column_default: None,
is_primary_key: false,
extra: Some("STORED GENERATED".to_string()),
comment: None,
..Default::default()
});
generated.original_position = Some(0);
let mut status = column("status");
status.data_type = "varchar(50)".to_string();
status.comment = "状态1".to_string();
status.original = Some(ColumnInfo {
name: "status".to_string(),
data_type: "varchar(50)".to_string(),
is_nullable: true,
column_default: None,
is_primary_key: false,
extra: None,
comment: None,
..Default::default()
});
status.original_position = Some(1);
let result = build_table_structure_change_sql(structure_change_options(
DatabaseType::Mysql,
None,
"product_info",
vec![generated, status],
));
assert_eq!(result.warnings, Vec::<String>::new());
assert_eq!(
result.statements,
vec!["ALTER TABLE `product_info` MODIFY COLUMN `status` varchar(50) COMMENT '状态1';"]
);
}
#[test]
fn mysql_generated_column_change_is_blocked_without_expression_metadata() {
let mut generated = column("total");
generated.data_type = "decimal(14,2)".to_string();
generated.extra = Some(ColumnExtra::default());
generated.original = Some(ColumnInfo {
name: "total".to_string(),
data_type: "decimal(12,2)".to_string(),
is_nullable: true,
column_default: None,
is_primary_key: false,
extra: Some("STORED GENERATED".to_string()),
comment: None,
..Default::default()
});
generated.original_position = Some(0);
let result = build_table_structure_change_sql(structure_change_options(
DatabaseType::Mysql,
None,
"products",
vec![generated],
));
assert!(result.statements.is_empty());
assert_eq!(result.warnings.len(), 1);
assert!(result.warnings[0].contains("generation expression could not be loaded"));
}
// ---- Oscar (神通) ----
// 神通 v7 是 Oracle 兼容方言,且实测支持 ALTER TABLE DROP/ADD PRIMARY KEY(与 Dameng 一致,
// 不同于 Oracle)。DDL 生成走 StructureDialect::Oscar,与 Dameng 共享 Oracle-like 分支。