feat(mysql): edit foreign keys and triggers

This commit is contained in:
t8y2 committed 2026-06-12 12:29:46 +08:00
1 parent 1d2d348dd8
commit a858a6bd18
23 files changed
+838 -22

No files matched your search

@@ -1,7 +1,9 @@
use super::column_format::{column_data_type, column_extra_clause};
use super::comments::{build_sqlserver_column_comment_sql, build_sqlserver_table_comment_sql};
use super::dialect::{capabilities_for, database_label, StructureDialect};
use super::foreign_keys::build_foreign_key_sql;
use super::indexes::build_create_index_statements;
use super::triggers::build_trigger_sql;
use super::types::{TableStructureSqlOptions, TableStructureSqlResult};
use super::util::{clean, format_default_for_sql, normalize_default, qualified_table, quote_ident, quote_string};
use super::validation::validate_columns;
@@ -136,5 +138,8 @@ pub fn build_create_table_sql(options: TableStructureSqlOptions) -> TableStructu
));
}
statements.extend(build_foreign_key_sql(&options, &mut warnings));
statements.extend(build_trigger_sql(&options, &mut warnings));
TableStructureSqlResult { statements, warnings }
}
@@ -0,0 +1,124 @@
use super::dialect::{database_label, StructureDialect};
use super::types::{EditableStructureForeignKey, ForeignKeyInfo, TableStructureSqlOptions};
use super::util::{clean, qualified_table, quote_ident};
pub(super) fn build_foreign_key_sql(options: &TableStructureSqlOptions, warnings: &mut Vec<String>) -> Vec<String> {
if options.foreign_keys.is_empty() {
return Vec::new();
}
let database_label = database_label(options.database_type);
if super::dialect::capabilities_for(options.database_type).dialect != StructureDialect::Mysql {
warnings.push(format!("Editing foreign keys is not supported for {database_label} from this editor."));
return Vec::new();
}
let table = qualified_table(StructureDialect::Mysql, options.schema.as_deref(), &options.table_name);
let mut statements = Vec::new();
for foreign_key in &options.foreign_keys {
if foreign_key.marked_for_drop {
if let Some(original) = &foreign_key.original {
statements.push(drop_foreign_key_sql(&table, &original.name));
}
continue;
}
if let Some(original) = &foreign_key.original {
if !has_foreign_key_change(foreign_key, original) {
continue;
}
statements.push(drop_foreign_key_sql(&table, &original.name));
}
if let Some(sql) = create_foreign_key_sql(&table, foreign_key, warnings) {
statements.push(sql);
}
}
statements
}
fn has_foreign_key_change(foreign_key: &EditableStructureForeignKey, original: &ForeignKeyInfo) -> bool {
clean(&foreign_key.name) != clean(&original.name)
|| clean(&foreign_key.column) != clean(&original.column)
|| clean(&foreign_key.ref_schema) != clean(original.ref_schema.as_deref().unwrap_or(""))
|| clean(&foreign_key.ref_table) != clean(&original.ref_table)
|| clean(&foreign_key.ref_column) != clean(&original.ref_column)
|| normalize_action(&foreign_key.on_update) != normalize_action(original.on_update.as_deref().unwrap_or(""))
|| normalize_action(&foreign_key.on_delete) != normalize_action(original.on_delete.as_deref().unwrap_or(""))
}
fn drop_foreign_key_sql(table: &str, name: &str) -> String {
format!("ALTER TABLE {table} DROP FOREIGN KEY {};", quote_ident(StructureDialect::Mysql, name))
}
fn create_foreign_key_sql(
table: &str,
foreign_key: &EditableStructureForeignKey,
warnings: &mut Vec<String>,
) -> Option<String> {
let name = clean(&foreign_key.name);
let columns = identifier_list(&foreign_key.column);
let ref_table = clean(&foreign_key.ref_table);
let ref_columns = identifier_list(&foreign_key.ref_column);
if name.is_empty() || columns.is_empty() || ref_table.is_empty() || ref_columns.is_empty() {
warnings.push("Foreign key name, column, referenced table, and referenced column are required.".to_string());
return None;
}
if columns.len() != ref_columns.len() {
warnings.push(format!("Foreign key \"{name}\" must have the same number of local and referenced columns."));
return None;
}
let ref_target = if clean(&foreign_key.ref_schema).is_empty() {
quote_ident(StructureDialect::Mysql, &ref_table)
} else {
format!(
"{}.{}",
quote_ident(StructureDialect::Mysql, &foreign_key.ref_schema),
quote_ident(StructureDialect::Mysql, &ref_table)
)
};
let mut sql = format!(
"ALTER TABLE {table} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {ref_target} ({}",
quote_ident(StructureDialect::Mysql, &name),
columns.iter().map(|column| quote_ident(StructureDialect::Mysql, column)).collect::<Vec<_>>().join(", "),
ref_columns.iter().map(|column| quote_ident(StructureDialect::Mysql, column)).collect::<Vec<_>>().join(", ")
);
sql.push(')');
if let Some(action) = action_clause("ON DELETE", &foreign_key.on_delete, warnings) {
sql.push(' ');
sql.push_str(&action);
}
if let Some(action) = action_clause("ON UPDATE", &foreign_key.on_update, warnings) {
sql.push(' ');
sql.push_str(&action);
}
sql.push(';');
Some(sql)
}
fn action_clause(prefix: &str, value: &str, warnings: &mut Vec<String>) -> Option<String> {
let action = normalize_action(value);
if action.is_empty() {
return None;
}
match action.as_str() {
"CASCADE" | "SET NULL" | "RESTRICT" | "NO ACTION" => Some(format!("{prefix} {action}")),
_ => {
warnings.push(format!("Unsupported foreign key action \"{}\".", clean(value)));
None
}
}
}
fn normalize_action(value: &str) -> String {
clean(value).split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_uppercase()
}
fn identifier_list(value: &str) -> Vec<String> {
value.split(',').map(clean).filter(|value| !value.is_empty()).collect()
}
@@ -33,6 +33,33 @@ fn index(name: &str, columns: &[&str]) -> EditableStructureIndex {
}
}
fn foreign_key(name: &str, column: &str, ref_table: &str, ref_column: &str) -> EditableStructureForeignKey {
EditableStructureForeignKey {
id: name.to_string(),
name: name.to_string(),
column: column.to_string(),
ref_schema: String::new(),
ref_table: ref_table.to_string(),
ref_column: ref_column.to_string(),
on_update: String::new(),
on_delete: String::new(),
original: None,
marked_for_drop: false,
}
}
fn trigger(name: &str, timing: &str, event: &str, statement: &str) -> EditableStructureTrigger {
EditableStructureTrigger {
id: name.to_string(),
name: name.to_string(),
timing: timing.to_string(),
event: event.to_string(),
statement: statement.to_string(),
original: None,
marked_for_drop: false,
}
}
#[test]
fn builds_mysql_column_and_index_changes() {
let mut renamed = column("display_name");
@@ -72,6 +99,8 @@ fn builds_mysql_column_and_index_changes() {
table_name: "users".to_string(),
columns: vec![renamed, email],
indexes: vec![old_index, email_index],
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
@@ -100,6 +129,8 @@ fn mysql_add_timestamp_column_drops_invalid_precision() {
table_name: "users".to_string(),
columns: vec![created_at],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
@@ -123,6 +154,8 @@ fn mysql_add_timestamp_column_preserves_valid_precision() {
table_name: "users".to_string(),
columns: vec![created_at],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
@@ -153,6 +186,8 @@ fn builds_postgres_create_table_with_comments_and_index() {
table_name: "users".to_string(),
columns: vec![id, name],
indexes: vec![idx],
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
@@ -189,6 +224,8 @@ fn warns_for_sqlite_unsafe_column_changes() {
table_name: "users".to_string(),
columns: vec![col],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
@@ -214,6 +251,8 @@ fn builds_rqlite_changes_with_sqlite_dialect() {
table_name: "users".to_string(),
columns: vec![email],
indexes: vec![email_index],
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
@@ -277,6 +316,8 @@ fn builds_mysql_column_reorder_statements() {
table_name: "users".to_string(),
columns: vec![id, email, name],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
@@ -303,6 +344,8 @@ fn builds_sql_server_quoted_column_and_index_statements() {
table_name: "users".to_string(),
columns: vec![email],
indexes: vec![index("idx_users_email", &["email"])],
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
@@ -333,6 +376,8 @@ fn builds_duckdb_create_table_statements() {
table_name: "events".to_string(),
columns: vec![name, created_at],
indexes: vec![index("idx_events_name", &["name"])],
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
@@ -373,6 +418,8 @@ fn builds_clickhouse_nullable_comment_and_reorder_statements() {
table_name: "events".to_string(),
columns: vec![source, status],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
@@ -414,6 +461,8 @@ fn builds_h2_schema_qualified_existing_column_statements() {
table_name: "USERS".to_string(),
columns: vec![name],
indexes: vec![index("IDX_USERS_DISPLAY_NAME", &["DISPLAY_NAME"])],
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
@@ -454,6 +503,8 @@ fn builds_postgres_alter_table_add_primary_key() {
table_name: "users".to_string(),
columns: vec![id],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
@@ -484,6 +535,8 @@ fn builds_postgres_alter_table_drop_primary_key() {
table_name: "users".to_string(),
columns: vec![id],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
@@ -530,6 +583,8 @@ fn builds_mysql_alter_table_change_primary_key() {
table_name: "users".to_string(),
columns: vec![old_pk, new_pk],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
@@ -563,6 +618,8 @@ fn builds_no_statements_when_primary_key_unchanged() {
table_name: "users".to_string(),
columns: vec![id],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
@@ -593,6 +650,8 @@ fn warns_sqlite_cannot_alter_primary_key() {
table_name: "users".to_string(),
columns: vec![id],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
@@ -616,6 +675,8 @@ fn mysql_create_table_with_auto_increment() {
table_name: "users".to_string(),
columns: vec![col],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
@@ -639,6 +700,8 @@ fn mysql_create_table_with_on_update_current_timestamp() {
table_name: "users".to_string(),
columns: vec![col],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
@@ -664,6 +727,8 @@ fn postgres_create_table_with_identity() {
table_name: "users".to_string(),
columns: vec![col],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
@@ -689,6 +754,8 @@ fn sqlserver_create_table_with_identity() {
table_name: "users".to_string(),
columns: vec![col],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
@@ -709,6 +776,8 @@ fn mysql_quotes_datetime_literal_default() {
table_name: "events".to_string(),
columns: vec![col],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
@@ -729,6 +798,8 @@ fn mysql_does_not_quote_current_timestamp() {
table_name: "events".to_string(),
columns: vec![col],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
@@ -750,6 +821,8 @@ fn mysql_does_not_quote_temporal_function_with_parens() {
table_name: "events".to_string(),
columns: vec![col],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
@@ -770,6 +843,8 @@ fn mysql_date_literal_default_is_quoted() {
table_name: "users".to_string(),
columns: vec![col],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
@@ -790,6 +865,8 @@ fn mysql_time_literal_default_is_quoted() {
table_name: "shifts".to_string(),
columns: vec![col],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
@@ -810,6 +887,8 @@ fn non_temporal_types_are_not_quoted() {
table_name: "games".to_string(),
columns: vec![col],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
@@ -868,3 +947,109 @@ 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 builds_mysql_foreign_key_changes() {
let mut existing = foreign_key("fk_orders_users", "user_id", "users", "id");
existing.on_delete = "CASCADE".to_string();
existing.original = Some(ForeignKeyInfo {
name: "fk_orders_users_old".to_string(),
column: "customer_id".to_string(),
ref_schema: None,
ref_table: "customers".to_string(),
ref_column: "id".to_string(),
on_update: None,
on_delete: Some("RESTRICT".to_string()),
});
let mut dropped = foreign_key("fk_orders_accounts", "account_id", "accounts", "id");
dropped.marked_for_drop = true;
dropped.original = Some(ForeignKeyInfo {
name: "fk_orders_accounts".to_string(),
column: "account_id".to_string(),
ref_schema: None,
ref_table: "accounts".to_string(),
ref_column: "id".to_string(),
on_update: None,
on_delete: None,
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Mysql),
schema: None,
table_name: "orders".to_string(),
columns: Vec::new(),
indexes: Vec::new(),
foreign_keys: vec![existing, dropped],
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
assert_eq!(
result.statements,
vec![
"ALTER TABLE `orders` DROP FOREIGN KEY `fk_orders_users_old`;",
"ALTER TABLE `orders` ADD CONSTRAINT `fk_orders_users` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE;",
"ALTER TABLE `orders` DROP FOREIGN KEY `fk_orders_accounts`;",
]
);
}
#[test]
fn builds_mysql_composite_foreign_key() {
let composite = foreign_key("fk_order_items_product", "tenant_id, product_id", "products", "tenant_id, id");
let result = build_table_structure_change_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Mysql),
schema: None,
table_name: "order_items".to_string(),
columns: Vec::new(),
indexes: Vec::new(),
foreign_keys: vec![composite],
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
assert_eq!(
result.statements,
vec![
"ALTER TABLE `order_items` ADD CONSTRAINT `fk_order_items_product` FOREIGN KEY (`tenant_id`, `product_id`) REFERENCES `products` (`tenant_id`, `id`);",
]
);
}
#[test]
fn builds_mysql_trigger_changes() {
let mut existing = trigger("orders_bu", "BEFORE", "UPDATE", "BEGIN\n SET NEW.updated_at = NOW();\nEND");
existing.original = Some(TriggerInfo {
name: "orders_bu".to_string(),
event: "UPDATE".to_string(),
timing: "BEFORE".to_string(),
statement: Some("SET NEW.updated_at = CURRENT_TIMESTAMP".to_string()),
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Mysql),
schema: None,
table_name: "orders".to_string(),
columns: Vec::new(),
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: vec![existing],
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
assert_eq!(
result.statements,
vec![
"DROP TRIGGER `orders_bu`;",
"CREATE TRIGGER `orders_bu` BEFORE UPDATE ON `orders` FOR EACH ROW\nBEGIN\n SET NEW.updated_at = NOW();\nEND;",
]
);
}
@@ -0,0 +1,94 @@
use super::dialect::{database_label, StructureDialect};
use super::types::{EditableStructureTrigger, TableStructureSqlOptions, TriggerInfo};
use super::util::{clean, qualified_table, quote_ident};
pub(super) fn build_trigger_sql(options: &TableStructureSqlOptions, warnings: &mut Vec<String>) -> Vec<String> {
if options.triggers.is_empty() {
return Vec::new();
}
let database_label = database_label(options.database_type);
if super::dialect::capabilities_for(options.database_type).dialect != StructureDialect::Mysql {
warnings.push(format!("Editing triggers is not supported for {database_label} from this editor."));
return Vec::new();
}
let table = qualified_table(StructureDialect::Mysql, options.schema.as_deref(), &options.table_name);
let mut statements = Vec::new();
for trigger in &options.triggers {
if trigger.marked_for_drop {
if let Some(original) = &trigger.original {
statements.push(drop_trigger_sql(options.schema.as_deref(), &original.name));
}
continue;
}
if let Some(original) = &trigger.original {
if !has_trigger_change(trigger, original) {
continue;
}
statements.push(drop_trigger_sql(options.schema.as_deref(), &original.name));
}
if let Some(sql) = create_trigger_sql(&table, trigger, warnings) {
statements.push(sql);
}
}
statements
}
fn has_trigger_change(trigger: &EditableStructureTrigger, original: &TriggerInfo) -> bool {
clean(&trigger.name) != clean(&original.name)
|| normalize_keyword(&trigger.timing) != normalize_keyword(&original.timing)
|| normalize_keyword(&trigger.event) != normalize_keyword(&original.event)
|| normalize_statement(&trigger.statement) != normalize_statement(original.statement.as_deref().unwrap_or(""))
}
fn drop_trigger_sql(schema: Option<&str>, name: &str) -> String {
let qualified_name = if schema.is_some_and(|schema| !schema.trim().is_empty()) {
format!(
"{}.{}",
quote_ident(StructureDialect::Mysql, schema.unwrap()),
quote_ident(StructureDialect::Mysql, name)
)
} else {
quote_ident(StructureDialect::Mysql, name)
};
format!("DROP TRIGGER {qualified_name};")
}
fn create_trigger_sql(table: &str, trigger: &EditableStructureTrigger, warnings: &mut Vec<String>) -> Option<String> {
let name = clean(&trigger.name);
let timing = normalize_keyword(&trigger.timing);
let event = normalize_keyword(&trigger.event);
let statement = clean(&trigger.statement);
if name.is_empty() || timing.is_empty() || event.is_empty() || statement.is_empty() {
warnings.push("Trigger name, timing, event, and statement are required.".to_string());
return None;
}
if !matches!(timing.as_str(), "BEFORE" | "AFTER") {
warnings.push(format!("Unsupported trigger timing \"{}\".", clean(&trigger.timing)));
return None;
}
if !matches!(event.as_str(), "INSERT" | "UPDATE" | "DELETE") {
warnings.push(format!("Unsupported trigger event \"{}\".", clean(&trigger.event)));
return None;
}
Some(format!(
"CREATE TRIGGER {} {timing} {event} ON {table} FOR EACH ROW\n{};",
quote_ident(StructureDialect::Mysql, &name),
statement.trim_end_matches(';').trim_end()
))
}
fn normalize_keyword(value: &str) -> String {
clean(value).to_ascii_uppercase()
}
fn normalize_statement(value: &str) -> String {
clean(value).trim_end_matches(';').trim().to_string()
}
@@ -105,6 +105,69 @@ pub struct IndexInfo {
pub comment: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EditableStructureForeignKey {
pub id: String,
pub name: String,
#[serde(default)]
pub column: String,
#[serde(default)]
pub ref_schema: String,
#[serde(default)]
pub ref_table: String,
#[serde(default)]
pub ref_column: String,
#[serde(default)]
pub on_update: String,
#[serde(default)]
pub on_delete: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub original: Option<ForeignKeyInfo>,
#[serde(default)]
pub marked_for_drop: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ForeignKeyInfo {
pub name: String,
pub column: String,
#[serde(default)]
pub ref_schema: Option<String>,
pub ref_table: String,
pub ref_column: String,
#[serde(default)]
pub on_update: Option<String>,
#[serde(default)]
pub on_delete: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EditableStructureTrigger {
pub id: String,
pub name: String,
#[serde(default)]
pub timing: String,
#[serde(default)]
pub event: String,
#[serde(default)]
pub statement: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub original: Option<TriggerInfo>,
#[serde(default)]
pub marked_for_drop: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TriggerInfo {
pub name: String,
pub event: String,
pub timing: String,
#[serde(default)]
pub statement: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TableStructureSqlOptions {
@@ -117,6 +180,10 @@ pub struct TableStructureSqlOptions {
pub columns: Vec<EditableStructureColumn>,
#[serde(default)]
pub indexes: Vec<EditableStructureIndex>,
#[serde(default)]
pub foreign_keys: Vec<EditableStructureForeignKey>,
#[serde(default)]
pub triggers: Vec<EditableStructureTrigger>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub table_comment: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]