fix(mysql): preserve generated columns in table editor
This commit is contained in:
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 分支。
|
||||
|
||||
Reference in new issue
Block a user