fix(sqlserver): edit triggers with events and disabled state
This commit is contained in:
6 files changed
+266
-11
No files matched your search
@@ -4922,6 +4922,7 @@ fn builds_mysql_trigger_changes() {
|
||||
event: "UPDATE".to_string(),
|
||||
timing: "BEFORE".to_string(),
|
||||
statement: Some("SET NEW.updated_at = CURRENT_TIMESTAMP".to_string()),
|
||||
enabled: None,
|
||||
});
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
@@ -4949,6 +4950,109 @@ fn builds_mysql_trigger_changes() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_sqlserver_trigger_with_multiple_events() {
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
schema: Some("dbo".to_string()),
|
||||
table_name: "orders".to_string(),
|
||||
columns: Vec::new(),
|
||||
indexes: Vec::new(),
|
||||
foreign_keys: Vec::new(),
|
||||
triggers: vec![trigger("orders_audit", "AFTER", "INSERT, UPDATE", "BEGIN\n SET NOCOUNT ON;\nEND")],
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
mysql_engine: None,
|
||||
partitioned: false,
|
||||
is_gaussdb_m_mode: false,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert_eq!(
|
||||
result.statements,
|
||||
vec![
|
||||
"CREATE TRIGGER [dbo].[orders_audit] ON [dbo].[orders] AFTER INSERT, UPDATE AS\nBEGIN\n SET NOCOUNT ON;\nEND;"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuilds_changed_sqlserver_trigger_from_complete_metadata_source() {
|
||||
let mut existing = trigger(
|
||||
"orders_audit",
|
||||
"AFTER",
|
||||
"INSERT, UPDATE",
|
||||
"CREATE TRIGGER dbo.orders_audit ON dbo.orders AFTER INSERT, UPDATE AS BEGIN SET NOCOUNT ON; INSERT INTO audit_log VALUES (1); END",
|
||||
);
|
||||
existing.original = Some(TriggerInfo {
|
||||
name: "orders_audit".to_string(),
|
||||
event: "INSERT, UPDATE".to_string(),
|
||||
timing: "AFTER".to_string(),
|
||||
statement: Some(
|
||||
"CREATE TRIGGER dbo.orders_audit ON dbo.orders AFTER INSERT, UPDATE AS BEGIN SET NOCOUNT ON; INSERT INTO audit_log VALUES (0); END"
|
||||
.to_string(),
|
||||
),
|
||||
enabled: None,
|
||||
});
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
schema: Some("dbo".to_string()),
|
||||
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,
|
||||
mysql_engine: None,
|
||||
partitioned: false,
|
||||
is_gaussdb_m_mode: false,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert_eq!(
|
||||
result.statements,
|
||||
vec![
|
||||
"DROP TRIGGER [dbo].[orders_audit];",
|
||||
"CREATE TRIGGER [dbo].[orders_audit] ON [dbo].[orders] AFTER INSERT, UPDATE AS\nBEGIN SET NOCOUNT ON; INSERT INTO audit_log VALUES (1); END;"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_trigger_edit_restores_disabled_state() {
|
||||
let mut existing =
|
||||
trigger("orders_audit", "AFTER", "INSERT", "BEGIN SET NOCOUNT ON; INSERT INTO audit_log VALUES (1); END");
|
||||
existing.original = Some(TriggerInfo {
|
||||
name: "orders_audit".to_string(),
|
||||
event: "INSERT".to_string(),
|
||||
timing: "AFTER".to_string(),
|
||||
statement: Some("CREATE TRIGGER dbo.orders_audit ON dbo.orders AFTER INSERT AS PRINT 'old'".to_string()),
|
||||
enabled: Some(false),
|
||||
});
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
schema: Some("dbo".to_string()),
|
||||
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,
|
||||
partitioned: false,
|
||||
mysql_engine: None,
|
||||
is_gaussdb_m_mode: false,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
result.statements.last().map(String::as_str),
|
||||
Some("DISABLE TRIGGER [dbo].[orders_audit] ON [dbo].[orders];")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_postgres_trigger_does_not_block_column_rename() {
|
||||
let mut renamed = column("display_name");
|
||||
@@ -4968,6 +5072,7 @@ fn unchanged_postgres_trigger_does_not_block_column_rename() {
|
||||
event: "UPDATE".to_string(),
|
||||
timing: "AFTER".to_string(),
|
||||
statement: Some("EXECUTE FUNCTION audit_users()".to_string()),
|
||||
enabled: None,
|
||||
});
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
@@ -4997,6 +5102,7 @@ fn changed_postgres_trigger_remains_unsupported() {
|
||||
event: "UPDATE".to_string(),
|
||||
timing: "AFTER".to_string(),
|
||||
statement: Some("EXECUTE FUNCTION audit_users()".to_string()),
|
||||
enabled: None,
|
||||
});
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
@@ -5031,6 +5137,7 @@ fn rejects_editing_existing_oracle_trigger_without_complete_source() {
|
||||
event: "INSERT OR UPDATE OR DELETE".to_string(),
|
||||
timing: "AFTER EACH ROW".to_string(),
|
||||
statement: Some("BEGIN\n NULL;\nEND;".to_string()),
|
||||
enabled: None,
|
||||
});
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
@@ -5089,6 +5196,7 @@ fn drops_existing_oracle_trigger_without_reconstructing_it() {
|
||||
event: "INSERT".to_string(),
|
||||
timing: "AFTER EACH ROW".to_string(),
|
||||
statement: Some("BEGIN\n NULL;\nEND;".to_string()),
|
||||
enabled: None,
|
||||
});
|
||||
existing.marked_for_drop = true;
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ pub(super) fn build_trigger_sql(options: &TableStructureSqlOptions, warnings: &m
|
||||
|
||||
let dialect = super::dialect::capabilities_for(options.database_type).dialect;
|
||||
let database_label = database_label(options.database_type);
|
||||
if !matches!(dialect, StructureDialect::Mysql | StructureDialect::Oracle) {
|
||||
if !matches!(dialect, StructureDialect::Mysql | StructureDialect::Oracle | StructureDialect::SqlServer) {
|
||||
if options.triggers.iter().any(has_trigger_edit) {
|
||||
warnings.push(format!("Editing triggers is not supported for {database_label} from this editor."));
|
||||
}
|
||||
@@ -48,12 +48,30 @@ pub(super) fn build_trigger_sql(options: &TableStructureSqlOptions, warnings: &m
|
||||
|
||||
if let Some(sql) = create_trigger_sql(dialect, options.schema.as_deref(), &table, trigger, warnings) {
|
||||
statements.push(sql);
|
||||
// SQL Server rebuilds via DROP + CREATE, which resets is_disabled to
|
||||
// enabled; restore the catalog-reported disabled state explicitly.
|
||||
// `table` is already qualified the same way the CREATE's ON clause is.
|
||||
if dialect == StructureDialect::SqlServer
|
||||
&& trigger.original.as_ref().is_some_and(|original| original.enabled == Some(false))
|
||||
{
|
||||
let schema = options.schema.as_deref();
|
||||
let trigger_name = qualified_trigger_object_name(dialect, schema, &trigger.name);
|
||||
statements.push(format!("DISABLE TRIGGER {trigger_name} ON {table};"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
statements
|
||||
}
|
||||
|
||||
fn qualified_trigger_object_name(dialect: StructureDialect, schema: Option<&str>, name: &str) -> String {
|
||||
if schema.is_some_and(|schema| !schema.trim().is_empty()) {
|
||||
format!("{}.{}", quote_ident(dialect, schema.unwrap()), quote_ident(dialect, name))
|
||||
} else {
|
||||
quote_ident(dialect, name)
|
||||
}
|
||||
}
|
||||
|
||||
fn has_trigger_edit(trigger: &EditableStructureTrigger) -> bool {
|
||||
trigger.marked_for_drop || trigger.original.as_ref().is_none_or(|original| has_trigger_change(trigger, original))
|
||||
}
|
||||
@@ -92,6 +110,9 @@ fn create_trigger_sql(
|
||||
}
|
||||
match dialect {
|
||||
StructureDialect::Mysql => create_mysql_trigger_sql(table, &name, &timing, &event, &statement, warnings),
|
||||
StructureDialect::SqlServer => {
|
||||
create_sqlserver_trigger_sql(schema, table, &name, &timing, &event, &statement, warnings)
|
||||
}
|
||||
StructureDialect::Oracle => {
|
||||
create_oracle_trigger_sql(schema, table, &name, &timing, &event, &statement, warnings)
|
||||
}
|
||||
@@ -99,6 +120,99 @@ fn create_trigger_sql(
|
||||
}
|
||||
}
|
||||
|
||||
fn create_sqlserver_trigger_sql(
|
||||
schema: Option<&str>,
|
||||
table: &str,
|
||||
name: &str,
|
||||
timing: &str,
|
||||
event: &str,
|
||||
statement: &str,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Option<String> {
|
||||
if !matches!(timing, "AFTER" | "INSTEAD OF") {
|
||||
warnings.push(format!("Unsupported SQL Server trigger timing \"{timing}\"."));
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut events = Vec::new();
|
||||
for item in event.split([',', '\n']) {
|
||||
let item = item.trim();
|
||||
if item.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let normalized_item = item.to_ascii_uppercase();
|
||||
for event in normalized_item.split(" OR ") {
|
||||
let event = event.trim().to_string();
|
||||
if !matches!(event.as_str(), "INSERT" | "UPDATE" | "DELETE") {
|
||||
warnings.push(format!("Unsupported SQL Server trigger event \"{}\".", item));
|
||||
return None;
|
||||
}
|
||||
if !events.contains(&event) {
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
if events.is_empty() {
|
||||
warnings.push("SQL Server trigger event is required.".to_string());
|
||||
return None;
|
||||
}
|
||||
|
||||
let trigger_name = if schema.is_some_and(|schema| !schema.trim().is_empty()) {
|
||||
format!(
|
||||
"{}.{}",
|
||||
quote_ident(StructureDialect::SqlServer, schema.unwrap()),
|
||||
quote_ident(StructureDialect::SqlServer, name)
|
||||
)
|
||||
} else {
|
||||
quote_ident(StructureDialect::SqlServer, name)
|
||||
};
|
||||
let body = sqlserver_trigger_body(statement);
|
||||
if body.is_empty() {
|
||||
warnings.push("SQL Server trigger statement is required.".to_string());
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"CREATE TRIGGER {trigger_name} ON {table} {timing} {} AS\n{};",
|
||||
events.join(", "),
|
||||
body.trim_end_matches(';').trim_end()
|
||||
))
|
||||
}
|
||||
|
||||
fn sqlserver_trigger_body(statement: &str) -> &str {
|
||||
let statement = statement.trim();
|
||||
if statement.get(..2).is_some_and(|prefix| prefix.eq_ignore_ascii_case("AS")) {
|
||||
return statement[2..].trim_start();
|
||||
}
|
||||
if !statement.to_ascii_uppercase().starts_with("CREATE TRIGGER") {
|
||||
return statement;
|
||||
}
|
||||
|
||||
// The body separator is the standalone AS that follows the event list
|
||||
// (`... AFTER INSERT, UPDATE AS <body>`); earlier AS tokens can appear in
|
||||
// trigger options such as `WITH EXECUTE AS OWNER`, so the first standalone
|
||||
// AS alone is not a reliable split point.
|
||||
let mut seen_event = false;
|
||||
let mut token = String::new();
|
||||
let mut char_indices = statement.char_indices().peekable();
|
||||
while let Some((index, ch)) = char_indices.next() {
|
||||
if ch.is_ascii_alphanumeric() || ch == '_' {
|
||||
token.push(ch);
|
||||
if !char_indices.peek().is_some_and(|(_, next)| next.is_ascii_alphanumeric() || *next == '_') {
|
||||
if token.eq_ignore_ascii_case("AS") && seen_event {
|
||||
return statement[index + 1..].trim_start();
|
||||
}
|
||||
if matches!(token.to_ascii_uppercase().as_str(), "INSERT" | "UPDATE" | "DELETE" | "LOGON") {
|
||||
seen_event = true;
|
||||
}
|
||||
token.clear();
|
||||
}
|
||||
} else {
|
||||
token.clear();
|
||||
}
|
||||
}
|
||||
statement
|
||||
}
|
||||
|
||||
fn create_mysql_trigger_sql(
|
||||
table: &str,
|
||||
name: &str,
|
||||
@@ -191,3 +305,19 @@ fn normalize_keyword(value: &str) -> String {
|
||||
fn normalize_statement(value: &str) -> String {
|
||||
clean(value).trim_end_matches(';').trim().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sqlserver_trigger_body_splits_after_event_list_not_execute_as() {
|
||||
// The AS inside `WITH EXECUTE AS OWNER` must not be treated as the body separator.
|
||||
let source = "CREATE TRIGGER dbo.t ON dbo.tbl WITH EXECUTE AS OWNER AFTER INSERT AS PRINT 'x'";
|
||||
assert_eq!(sqlserver_trigger_body(source), "PRINT 'x'");
|
||||
let no_options = "CREATE TRIGGER dbo.t ON dbo.tbl AFTER UPDATE AS SELECT 1 AS a";
|
||||
assert_eq!(sqlserver_trigger_body(no_options), "SELECT 1 AS a");
|
||||
let leading_as = "AS SELECT 2";
|
||||
assert_eq!(sqlserver_trigger_body(leading_as), "SELECT 2");
|
||||
}
|
||||
}
|
||||
@@ -188,6 +188,10 @@ pub struct TriggerInfo {
|
||||
pub timing: String,
|
||||
#[serde(default)]
|
||||
pub statement: Option<String>,
|
||||
/// Carries the catalog-reported enabled state so SQL Server edits can
|
||||
/// restore it after the DROP + CREATE rebuild (`DISABLE TRIGGER`).
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
Reference in new issue
Block a user