fix(mysql): preserve generated columns in table editor
This commit is contained in:
7 files changed
+481
-7
No files matched your search
@@ -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 分支。
|
||||
|
||||
Reference in new issue
Block a user