fix(sqlserver): edit triggers with events and disabled state

This commit is contained in:
zipg
2026-08-31 14:04:13 +08:00
committed by GitHub
parent d459628141
commit d96da5dece
6 changed files with 266 additions and 11 deletions
@@ -1022,7 +1022,7 @@ const filteredIndexRowIds = computed(() => {
});
const indexSearchMatchCount = computed(() => (indexSearchText.value.trim() ? filteredIndexRowIds.value.size : 0));
const foreignKeyActionOptions = ["", "CASCADE", "SET NULL", "RESTRICT", "NO ACTION"];
const triggerTimingOptions = ["BEFORE", "AFTER"];
const triggerTimingOptions = computed(() => (databaseType.value === "sqlserver" ? ["AFTER", "INSTEAD OF"] : ["BEFORE", "AFTER"]));
const triggerEventOptions = ["INSERT", "UPDATE", "DELETE"];
const metadataSchema = computed(() => connectionObjectTreeQuerySchema(connection.value, props.database, props.schema));
const refreshVersion = computed(() => (props.connectionId && props.tableName ? queryStore.tableStructureRefreshVersion(props.connectionId, props.database, props.schema, props.tableName) : 0));
@@ -3269,8 +3269,9 @@ function canDropIndex(index: EditableStructureIndex): boolean {
}
const canEditForeignKeys = computed(() => structureCapabilities.value.foreignKey);
const canEditTriggers = computed(() => structureDialect.value === "mysql" || structureDialect.value === "oracle");
const canEditTriggers = computed(() => structureDialect.value === "mysql" || structureDialect.value === "oracle" || structureDialect.value === "sqlserver");
const isOracleTriggerEditor = computed(() => structureDialect.value === "oracle");
const isSqlServerTriggerEditor = computed(() => structureDialect.value === "sqlserver");
function generatedForeignKeyName(column = ""): string {
const table = structureIndexTableName() || "table";
@@ -3323,9 +3324,9 @@ function addTrigger() {
triggers.value.push({
id: `new:${uuid()}`,
name: "",
timing: isOracleTriggerEditor.value ? "BEFORE EACH ROW" : "BEFORE",
timing: isOracleTriggerEditor.value ? "BEFORE EACH ROW" : isSqlServerTriggerEditor.value ? "AFTER" : "BEFORE",
event: "INSERT",
statement: isOracleTriggerEditor.value ? "BEGIN\n NULL;\nEND" : "BEGIN\n \nEND",
statement: isOracleTriggerEditor.value ? "BEGIN\n NULL;\nEND" : isSqlServerTriggerEditor.value ? "BEGIN\n SET NOCOUNT ON;\nEND" : "BEGIN\n \nEND",
markedForDrop: false,
});
}
@@ -4725,7 +4726,7 @@ watch([activeTab, loading, ddlLoading, ddlContent], ([tab, structureIsLoading, d
<SelectItem v-for="timing in triggerTimingOptions" :key="timing" :value="timing">{{ timing }}</SelectItem>
</SelectContent>
</Select>
<Input v-if="isOracleTriggerEditor" v-model="trigger.event" :class="structureControlClass" :disabled="!canEditTriggerDraft(trigger)" />
<Input v-if="isOracleTriggerEditor || isSqlServerTriggerEditor" v-model="trigger.event" :class="structureControlClass" :disabled="!canEditTriggerDraft(trigger)" />
<Select v-else v-model="trigger.event" :disabled="!canEditTriggerDraft(trigger)">
<SelectTrigger class="h-[var(--structure-control-height)] rounded-[6px] px-[var(--structure-control-px)] text-[length:var(--structure-font-size)] focus-visible:border-ring/50 focus-visible:ring-1 focus-visible:ring-ring/25">
<SelectValue />
@@ -4735,6 +4736,9 @@ watch([activeTab, loading, ddlLoading, ddlContent], ([tab, structureIsLoading, d
</SelectContent>
</Select>
<div class="flex items-center justify-end gap-1">
<Badge v-if="trigger.original && trigger.original.enabled !== undefined && trigger.original.enabled !== null" variant="outline" class="shrink-0 text-[length:var(--structure-font-size)]">
{{ trigger.original.enabled ? t("damengJobAdmin.enabled") : t("damengJobAdmin.disabled") }}
</Badge>
<Button v-if="trigger.original" variant="ghost" size="sm" :class="structureToolbarButtonClass" @click="toggleDropTrigger(trigger)">
<Trash2 :class="structureIconClass" />
{{ trigger.markedForDrop ? t("structureEditor.restore") : t("structureEditor.drop") }}
@@ -51,6 +51,7 @@ describe("tableStructureEditorState", () => {
expect(canEditStructuredTriggerDraft("oracle", existing)).toBe(false);
expect(canEditStructuredTriggerDraft(undefined, existing)).toBe(false);
expect(canEditStructuredTriggerDraft("mysql", existing)).toBe(true);
expect(canEditStructuredTriggerDraft("sqlserver", existing)).toBe(true);
expect(
canEditStructuredTriggerDraft("oracle", {
id: "new:trigger",
+13 -5
View File
@@ -2849,7 +2849,7 @@ pub async fn list_triggers(
level: None,
condition: None,
language: None,
enabled: None,
enabled: row.get::<bool, _>(4),
valid: None,
comment: None,
created_at: None,
@@ -2860,10 +2860,16 @@ pub async fn list_triggers(
fn sqlserver_triggers_sql(schema: &str, table: &str) -> String {
format!(
"SELECT t.name, te.type_desc, CASE WHEN t.is_instead_of_trigger = 1 THEN 'INSTEAD OF' ELSE 'AFTER' END, \
OBJECT_DEFINITION(t.object_id) \
"SELECT t.name, \
STUFF((SELECT ', ' + te2.type_desc \
FROM sys.trigger_events te2 \
WHERE te2.object_id = t.object_id \
ORDER BY te2.type_desc \
FOR XML PATH(''), TYPE).value('.', 'NVARCHAR(MAX)'), 1, 2, ''), \
CASE WHEN t.is_instead_of_trigger = 1 THEN 'INSTEAD OF' ELSE 'AFTER' END, \
OBJECT_DEFINITION(t.object_id), \
CASE WHEN t.is_disabled = 1 THEN CAST(0 AS bit) ELSE CAST(1 AS bit) END \
FROM sys.triggers t \
JOIN sys.trigger_events te ON t.object_id = te.object_id \
WHERE t.parent_id = OBJECT_ID('{s}.{t}') \
ORDER BY t.name",
s = schema.replace('\'', "''"),
@@ -4116,7 +4122,9 @@ mod tests {
let sql = sqlserver_triggers_sql("d'bo", "t'able");
assert!(sql.contains("OBJECT_DEFINITION(t.object_id)"));
assert!(sql.contains("JOIN sys.trigger_events te ON t.object_id = te.object_id"));
assert!(sql.contains("STUFF((SELECT ', ' + te2.type_desc"));
assert!(sql.contains("FOR XML PATH(''), TYPE"));
assert!(sql.contains("t.is_disabled"));
assert!(sql.contains("OBJECT_ID('d''bo.t''able')"));
assert!(sql.contains("ORDER BY t.name"));
assert!(!sql.contains("STRING_AGG"));
@@ -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)]