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

+141
View File
@@ -3562,6 +3562,94 @@ fn columns_sql(database: &str, table: &str) -> String {
)
}
fn generation_expressions_sql(database: &str, table: &str) -> String {
format!(
"SELECT COLUMN_NAME, GENERATION_EXPRESSION \
FROM information_schema.COLUMNS \
WHERE TABLE_SCHEMA = {} AND TABLE_NAME = {} \
AND GENERATION_EXPRESSION IS NOT NULL AND GENERATION_EXPRESSION <> '' \
ORDER BY ORDINAL_POSITION",
quote_value(database),
quote_value(table),
)
}
fn mysql_generated_column_storage(extra: &str) -> Option<&'static str> {
let normalized = extra.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
if normalized.contains("virtual generated") {
Some("VIRTUAL")
} else if normalized.contains("stored generated") {
Some("STORED")
} else if normalized.contains("persistent generated") {
Some("PERSISTENT")
} else {
None
}
}
fn mysql_generated_column_extra(extra: &str, expression: &str) -> Option<String> {
let storage = mysql_generated_column_storage(extra)?;
let expression = expression.trim();
if expression.is_empty() {
return None;
}
Some(format!("GENERATED ALWAYS AS ({expression}) {storage}"))
}
async fn enrich_mysql_generated_column_expressions(
conn: &mut mysql_async::Conn,
database: &str,
table: &str,
columns: &mut [ColumnInfo],
) {
if database.trim().is_empty()
|| !columns
.iter()
.any(|column| column.extra.as_deref().is_some_and(|extra| mysql_generated_column_storage(extra).is_some()))
{
return;
}
let sql = generation_expressions_sql(database, table);
let rows = match conn.query_iter(&sql).await {
Ok(result) => match result.collect_and_drop::<mysql_async::Row>().await {
Ok(rows) => rows,
Err(error) => {
log::debug!("Failed to collect MySQL generated-column expressions with `{sql}`: {error}");
return;
}
},
Err(error) => {
log::debug!("Failed to read MySQL generated-column expressions with `{sql}`: {error}");
return;
}
};
let expressions = rows
.iter()
.filter_map(|row| {
let name = get_str_by_name(row, "COLUMN_NAME");
let expression = get_str_by_name(row, "GENERATION_EXPRESSION");
(!name.is_empty() && !expression.trim().is_empty()).then_some((name, expression))
})
.collect::<HashMap<_, _>>();
apply_mysql_generated_column_expressions(columns, &expressions);
}
fn apply_mysql_generated_column_expressions(columns: &mut [ColumnInfo], expressions: &HashMap<String, String>) {
for column in columns {
let Some(expression) = expressions.get(&column.name) else {
continue;
};
let Some(extra) = column.extra.as_deref() else {
continue;
};
if let Some(generated_extra) = mysql_generated_column_extra(extra, expression) {
column.extra = Some(generated_extra);
}
}
}
fn table_collation_sql(database: &str, table: &str) -> String {
format!(
"SELECT TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA = {} AND TABLE_NAME = {} LIMIT 1",
@@ -3778,6 +3866,7 @@ where
return get_columns_show(pool, database, table).await;
}
enrich_mysql_generated_column_expressions(&mut conn, database, table, &mut columns).await;
normalize_mysql_column_charset_metadata(&mut columns, table_collation.as_deref());
Ok(columns)
}
@@ -3834,6 +3923,7 @@ where
})
})
.collect();
enrich_mysql_generated_column_expressions(&mut conn, database, table, &mut columns).await;
normalize_mysql_column_charset_metadata(&mut columns, table_collation.as_deref());
Ok(columns)
}
@@ -6603,6 +6693,57 @@ mod tests {
assert!(!sql.contains("AS ENUM_VALUES"));
}
#[test]
fn mysql_generation_expression_sql_is_separate_and_scoped() {
let sql = generation_expressions_sql("app", "products");
assert!(sql.contains("COLUMN_NAME, GENERATION_EXPRESSION"));
assert!(sql.contains("TABLE_SCHEMA = 'app'"));
assert!(sql.contains("TABLE_NAME = 'products'"));
assert!(sql.contains("GENERATION_EXPRESSION <> ''"));
}
#[test]
fn mysql_generated_column_extra_rebuilds_virtual_and_stored_clauses() {
assert_eq!(
mysql_generated_column_extra("STORED GENERATED", "`price` * `quantity`"),
Some("GENERATED ALWAYS AS (`price` * `quantity`) STORED".to_string())
);
assert_eq!(
mysql_generated_column_extra("VIRTUAL GENERATED", "lower(`name`)"),
Some("GENERATED ALWAYS AS (lower(`name`)) VIRTUAL".to_string())
);
assert_eq!(mysql_generated_column_extra("DEFAULT_GENERATED", "current_timestamp()"), None);
}
#[test]
fn mysql_generated_column_expressions_enrich_only_generated_columns() {
let mut columns = vec![
ColumnInfo { name: "total".to_string(), extra: Some("STORED GENERATED".to_string()), ..Default::default() },
ColumnInfo {
name: "search_name".to_string(),
extra: Some("VIRTUAL GENERATED".to_string()),
..Default::default()
},
ColumnInfo {
name: "created_at".to_string(),
extra: Some("DEFAULT_GENERATED".to_string()),
..Default::default()
},
];
let expressions = HashMap::from([
("total".to_string(), "`price` * `quantity`".to_string()),
("search_name".to_string(), "lower(`name`)".to_string()),
("created_at".to_string(), "current_timestamp()".to_string()),
]);
apply_mysql_generated_column_expressions(&mut columns, &expressions);
assert_eq!(columns[0].extra.as_deref(), Some("GENERATED ALWAYS AS (`price` * `quantity`) STORED"));
assert_eq!(columns[1].extra.as_deref(), Some("GENERATED ALWAYS AS (lower(`name`)) VIRTUAL"));
assert_eq!(columns[2].extra.as_deref(), Some("DEFAULT_GENERATED"));
}
#[test]
fn mysql_nullable_table_collation_uses_optional_string_conversion() {
let collation = mysql_async::from_value_opt::<Option<String>>(mysql_async::Value::NULL)
@@ -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 分支。