fix(oracle): add trigger operations in structure edit

This commit is contained in:
Loc Nguyen
2026-10-14 14:06:30 +08:00
committed by GitHub
parent 62c8720316
commit e18c60018c
9 changed files with 380 additions and 45 deletions
+44 -3
View File
@@ -235,6 +235,9 @@ SELECT t.TRIGGER_NAME,
t.TRIGGERING_EVENT,
t.TRIGGER_TYPE,
t.DESCRIPTION,
t.STATUS,
t.WHEN_CLAUSE,
t.REFERENCING_NAMES,
s.LINE,
s.TEXT
FROM ALL_TRIGGERS t
@@ -523,6 +526,7 @@ type triggerInfo struct {
Event string `json:"event"`
Timing string `json:"timing"`
Statement *string `json:"statement,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
// constraintInfo represents primary key, unique, and check constraints for a
@@ -2977,28 +2981,38 @@ func (s *server) listTriggers(schema, table string) ([]triggerInfo, error) {
var result []triggerInfo
var currentName string
var currentDescription string
var currentStructured bool
var source strings.Builder
flush := func() {
if len(result) == 0 || currentName == "" {
return
}
if !currentStructured {
// Keep the declaration so callers can tell the source carries clauses the
// timing/event/body form cannot round-trip.
if body := strings.TrimSpace(strings.ReplaceAll(source.String(), "\r\n", "\n")); body != "" {
result[len(result)-1].Statement = &body
}
return
}
if body, ok := oracleTriggerBody(source.String(), currentDescription); ok {
result[len(result)-1].Statement = &body
}
}
for rows.Next() {
var name, event, timing string
var description, lineText sql.NullString
var description, status, whenClause, referencingNames, lineText sql.NullString
var line sql.NullInt64
if err := rows.Scan(&name, &event, &timing, &description, &line, &lineText); err != nil {
if err := rows.Scan(&name, &event, &timing, &description, &status, &whenClause, &referencingNames, &line, &lineText); err != nil {
return nil, err
}
if name != currentName {
flush()
currentName = name
currentDescription = description.String
currentStructured = oracleTriggerIsStructured(description.String, whenClause.String, referencingNames.String)
source.Reset()
result = append(result, triggerInfo{Name: name, Event: event, Timing: timing})
result = append(result, triggerInfo{Name: name, Event: event, Timing: timing, Enabled: oracleTriggerEnabled(status)})
}
if line.Valid && lineText.Valid {
source.WriteString(lineText.String)
@@ -3008,6 +3022,33 @@ func (s *server) listTriggers(schema, table string) ([]triggerInfo, error) {
return emptyIfNil(result), rows.Err()
}
var oracleTriggerUnstructuredClauseRegexp = regexp.MustCompile(`(?i)\b(?:FOLLOWS|PRECEDES|COMPOUND|CROSSEDITION)\b`)
// oracleTriggerIsStructured reports whether a trigger is fully described by its timing, event and
// PL/SQL body, so it can be rebuilt with CREATE OR REPLACE without dropping a WHEN condition,
// custom REFERENCING names or FOLLOWS/PRECEDES ordering.
func oracleTriggerIsStructured(description, whenClause, referencingNames string) bool {
if strings.TrimSpace(whenClause) != "" {
return false
}
if referencing := strings.Join(strings.Fields(strings.ToUpper(referencingNames)), " "); referencing != "" && referencing != "REFERENCING NEW AS NEW OLD AS OLD" {
return false
}
return !oracleTriggerUnstructuredClauseRegexp.MatchString(description)
}
func oracleTriggerEnabled(status sql.NullString) *bool {
switch strings.ToUpper(strings.TrimSpace(status.String)) {
case "ENABLED":
enabled := true
return &enabled
case "DISABLED":
enabled := false
return &enabled
}
return nil
}
func oracleTriggerBody(source, description string) (string, bool) {
source = strings.ReplaceAll(source, "\r\n", "\n")
description = strings.ReplaceAll(description, "\r\n", "\n")
+73 -2
View File
@@ -1109,8 +1109,8 @@ func TestListForeignKeysAndTriggersPreserveQuotedCloneTableName(t *testing.T) {
{
queryContains: "FROM ALL_TRIGGERS",
args: []driver.Value{schema, table},
columns: []string{"TRIGGER_NAME", "TRIGGERING_EVENT", "TRIGGER_TYPE", "DESCRIPTION", "LINE", "TEXT"},
rows: [][]driver.Value{{"ORDERS_copy_TRG1", "INSERT", "BEFORE EACH ROW", nil, nil, nil}},
columns: []string{"TRIGGER_NAME", "TRIGGERING_EVENT", "TRIGGER_TYPE", "DESCRIPTION", "STATUS", "WHEN_CLAUSE", "REFERENCING_NAMES", "LINE", "TEXT"},
rows: [][]driver.Value{{"ORDERS_copy_TRG1", "INSERT", "BEFORE EACH ROW", nil, "DISABLED", nil, nil, nil, nil}},
},
})
s := newServer()
@@ -1130,6 +1130,9 @@ func TestListForeignKeysAndTriggersPreserveQuotedCloneTableName(t *testing.T) {
if len(triggers) != 1 || triggers[0].Name != "ORDERS_copy_TRG1" {
t.Fatalf("listTriggers(%q) = %#v, want quoted clone trigger", table, triggers)
}
if triggers[0].Enabled == nil || *triggers[0].Enabled {
t.Fatalf("listTriggers(%q) enabled = %v, want disabled from ALL_TRIGGERS.STATUS", table, triggers[0].Enabled)
}
if scripted.next != len(scripted.steps) {
t.Fatalf("expected %d queries, got %d", len(scripted.steps), scripted.next)
}
@@ -2216,6 +2219,11 @@ func TestOracleListTriggersSQLLoadsSourceWithoutLongColumns(t *testing.T) {
if !strings.Contains(sqlText, "T.DESCRIPTION") || !strings.Contains(sqlText, "S.TEXT") {
t.Fatalf("trigger listing should load the declaration and source text, got: %s", oracleListTriggersSQL)
}
for _, column := range []string{"T.STATUS", "T.WHEN_CLAUSE", "T.REFERENCING_NAMES"} {
if !strings.Contains(sqlText, column) {
t.Fatalf("trigger listing should load %s, got: %s", column, oracleListTriggersSQL)
}
}
if strings.Contains(sqlText, "TRIGGER_BODY") {
t.Fatalf("trigger listing should avoid Oracle LONG trigger bodies, got: %s", oracleListTriggersSQL)
}
@@ -2247,6 +2255,69 @@ func TestOracleTriggerBodyStripsDictionaryDeclaration(t *testing.T) {
}
}
func TestListTriggersKeepsDeclarationForUnstructuredTriggers(t *testing.T) {
const schema = "HR"
const table = "ORDERS"
columns := []string{"TRIGGER_NAME", "TRIGGERING_EVENT", "TRIGGER_TYPE", "DESCRIPTION", "STATUS", "WHEN_CLAUSE", "REFERENCING_NAMES", "LINE", "TEXT"}
db, _ := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
{
queryContains: "FROM ALL_TRIGGERS",
args: []driver.Value{schema, table},
columns: columns,
rows: [][]driver.Value{
{"PLAIN_TRG", "INSERT", "BEFORE EACH ROW", "PLAIN_TRG\nBEFORE INSERT ON ORDERS\nFOR EACH ROW\n", "ENABLED", nil, "REFERENCING NEW AS NEW OLD AS OLD", int64(1), "TRIGGER PLAIN_TRG\n"},
{"PLAIN_TRG", "INSERT", "BEFORE EACH ROW", "PLAIN_TRG\nBEFORE INSERT ON ORDERS\nFOR EACH ROW\n", "ENABLED", nil, "REFERENCING NEW AS NEW OLD AS OLD", int64(2), "BEFORE INSERT ON ORDERS\n"},
{"PLAIN_TRG", "INSERT", "BEFORE EACH ROW", "PLAIN_TRG\nBEFORE INSERT ON ORDERS\nFOR EACH ROW\n", "ENABLED", nil, "REFERENCING NEW AS NEW OLD AS OLD", int64(3), "FOR EACH ROW\n"},
{"PLAIN_TRG", "INSERT", "BEFORE EACH ROW", "PLAIN_TRG\nBEFORE INSERT ON ORDERS\nFOR EACH ROW\n", "ENABLED", nil, "REFERENCING NEW AS NEW OLD AS OLD", int64(4), "BEGIN NULL; END;\n"},
{"WHEN_TRG", "INSERT", "BEFORE EACH ROW", "WHEN_TRG\nBEFORE INSERT ON ORDERS\nFOR EACH ROW\n", "ENABLED", "NEW.STATUS = 'OPEN'", "REFERENCING NEW AS NEW OLD AS OLD", int64(1), "TRIGGER WHEN_TRG\n"},
{"WHEN_TRG", "INSERT", "BEFORE EACH ROW", "WHEN_TRG\nBEFORE INSERT ON ORDERS\nFOR EACH ROW\n", "ENABLED", "NEW.STATUS = 'OPEN'", "REFERENCING NEW AS NEW OLD AS OLD", int64(2), "BEFORE INSERT ON ORDERS\n"},
{"WHEN_TRG", "INSERT", "BEFORE EACH ROW", "WHEN_TRG\nBEFORE INSERT ON ORDERS\nFOR EACH ROW\n", "ENABLED", "NEW.STATUS = 'OPEN'", "REFERENCING NEW AS NEW OLD AS OLD", int64(3), "FOR EACH ROW\n"},
{"WHEN_TRG", "INSERT", "BEFORE EACH ROW", "WHEN_TRG\nBEFORE INSERT ON ORDERS\nFOR EACH ROW\n", "ENABLED", "NEW.STATUS = 'OPEN'", "REFERENCING NEW AS NEW OLD AS OLD", int64(4), "WHEN (NEW.STATUS = 'OPEN')\n"},
{"WHEN_TRG", "INSERT", "BEFORE EACH ROW", "WHEN_TRG\nBEFORE INSERT ON ORDERS\nFOR EACH ROW\n", "ENABLED", "NEW.STATUS = 'OPEN'", "REFERENCING NEW AS NEW OLD AS OLD", int64(5), "BEGIN NULL; END;\n"},
},
},
})
s := newServer()
s.db = db
triggers, err := s.listTriggers(schema, table)
if err != nil {
t.Fatal(err)
}
if len(triggers) != 2 || triggers[0].Statement == nil || triggers[1].Statement == nil {
t.Fatalf("listTriggers = %#v, want two triggers with source", triggers)
}
if got := *triggers[0].Statement; got != "BEGIN NULL; END;" {
t.Fatalf("plain trigger statement = %q, want stripped body", got)
}
if got := *triggers[1].Statement; !strings.HasPrefix(got, "TRIGGER WHEN_TRG\n") || !strings.Contains(got, "WHEN (NEW.STATUS = 'OPEN')") {
t.Fatalf("WHEN trigger statement = %q, want full declaration kept", got)
}
if triggers[0].Enabled == nil || !*triggers[0].Enabled {
t.Fatalf("plain trigger enabled = %v, want true", triggers[0].Enabled)
}
}
func TestOracleTriggerIsStructured(t *testing.T) {
const description = "AUDIT_TRG\nBEFORE INSERT ON ORDERS\nFOR EACH ROW\n"
const defaultReferencing = "REFERENCING NEW AS NEW OLD AS OLD"
if !oracleTriggerIsStructured(description, "", defaultReferencing) {
t.Fatal("plain row trigger should be structured")
}
if !oracleTriggerIsStructured("AUDIT_TRG\nAFTER INSERT ON ORDERS\n", "", "") {
t.Fatal("statement trigger without referencing names should be structured")
}
if oracleTriggerIsStructured(description, "NEW.ID > 0", defaultReferencing) {
t.Fatal("WHEN condition should not be structured")
}
if oracleTriggerIsStructured(description, "", "REFERENCING NEW AS N OLD AS O") {
t.Fatal("custom REFERENCING names should not be structured")
}
if oracleTriggerIsStructured("AUDIT_TRG\nBEFORE INSERT ON ORDERS\nFOR EACH ROW\nFOLLOWS OTHER_TRG\n", "", defaultReferencing) {
t.Fatal("FOLLOWS ordering should not be structured")
}
}
func TestOracleTriggerBodyFallsBackToVisibleSource(t *testing.T) {
body, ok := oracleTriggerBody("TRIGGER APP.AUDIT\nBEGIN\n NULL;\nEND;\n", "differently formatted declaration")
if !ok {
@@ -1012,7 +1012,7 @@ function foreignKeyChanged(foreignKey: EditableStructureForeignKey): boolean {
function triggerChanged(trigger: EditableStructureTrigger): boolean {
if (!trigger.original || trigger.markedForDrop) return true;
const original = trigger.original;
return trigger.name !== original.name || trigger.timing !== original.timing || trigger.event !== original.event || !sameText(trigger.statement, original.statement);
return trigger.name !== original.name || trigger.timing !== original.timing || trigger.event !== original.event || !sameText(trigger.statement, original.statement) || (trigger.enabled !== undefined && trigger.enabled !== original.enabled);
}
function captureStructureRefreshScope(): TableStructureRefreshScope {
@@ -5150,6 +5150,7 @@ function addTrigger() {
timing: isOracleTriggerEditor.value ? "BEFORE EACH ROW" : isSqlServerTriggerEditor.value ? "AFTER" : "BEFORE",
event: "INSERT",
statement: isOracleTriggerEditor.value ? "BEGIN\n NULL;\nEND" : isSqlServerTriggerEditor.value ? "BEGIN\n SET NOCOUNT ON;\nEND" : "BEGIN\n \nEND",
enabled: true,
markedForDrop: false,
};
triggers.value.push(draft);
@@ -5166,7 +5167,13 @@ function toggleDropTrigger(trigger: EditableStructureTrigger) {
}
function canEditTriggerDraft(trigger: EditableStructureTrigger): boolean {
return !triggersLoading.value && canEditTriggers.value && !trigger.markedForDrop && canEditStructuredTriggerDraft(databaseType.value, trigger);
return canToggleTriggerEnabled(trigger) && canEditStructuredTriggerDraft(databaseType.value, trigger);
}
// ALTER TRIGGER ... ENABLE/DISABLE leaves the source untouched, so it stays available for
// triggers whose source the structured form cannot rebuild.
function canToggleTriggerEnabled(trigger: EditableStructureTrigger): boolean {
return !triggersLoading.value && canEditTriggers.value && !trigger.markedForDrop;
}
function primarySqlOperation(sql: string): string {
@@ -7016,8 +7023,18 @@ watch(
<SelectItem v-for="event in triggerEventOptions" :key="event" :value="event">{{ event }}</SelectItem>
</SelectContent>
</Select>
<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") }}
<Button
v-if="isOracleTriggerEditor && trigger.enabled !== undefined"
variant="outline"
size="sm"
:class="['h-[var(--structure-control-height)] shrink-0 px-[var(--structure-control-px)] text-[length:var(--structure-font-size)]', !trigger.enabled ? 'text-muted-foreground' : '']"
:disabled="!canToggleTriggerEnabled(trigger)"
@click.stop="trigger.enabled = !trigger.enabled"
>
{{ trigger.enabled ? t("damengJobAdmin.enabled") : t("damengJobAdmin.disabled") }}
</Button>
<Badge v-else-if="trigger.enabled !== undefined && trigger.enabled !== null" variant="outline" class="shrink-0 text-[length:var(--structure-font-size)]">
{{ trigger.enabled ? t("damengJobAdmin.enabled") : t("damengJobAdmin.disabled") }}
</Badge>
<Button v-if="trigger.original" variant="ghost" size="sm" :class="structureToolbarButtonClass" @click.stop="toggleDropTrigger(trigger)">
<Trash2 :class="structureIconClass" />
@@ -368,11 +368,18 @@ describe("tableStructureEditorState", () => {
});
});
it("keeps existing Oracle trigger drafts read-only until full source editing is available", () => {
const [existing] = createTriggerDrafts([{ name: "ORDERS_AUDIT", timing: "AFTER EACH ROW", event: "INSERT OR UPDATE", statement: "BEGIN NULL; END;" }]);
if (!existing) throw new Error("expected an existing trigger draft");
it("allows existing Oracle trigger drafts to be edited only when the source is a plain body", () => {
const [existing, unstructured, missingSource] = createTriggerDrafts([
{ name: "ORDERS_AUDIT", timing: "AFTER EACH ROW", event: "INSERT OR UPDATE", statement: "BEGIN NULL; END;", enabled: false },
{ name: "ORDERS_WHEN", timing: "BEFORE EACH ROW", event: "INSERT", statement: "TRIGGER ORDERS_WHEN\nBEFORE INSERT ON ORDERS\nFOR EACH ROW\nWHEN (NEW.ID > 0)\nBEGIN NULL; END;" },
{ name: "ORDERS_EMPTY", timing: "BEFORE EACH ROW", event: "INSERT" },
]);
if (!existing || !unstructured || !missingSource) throw new Error("expected existing trigger drafts");
expect(canEditStructuredTriggerDraft("oracle", existing)).toBe(false);
expect(existing.enabled).toBe(false);
expect(canEditStructuredTriggerDraft("oracle", existing)).toBe(true);
expect(canEditStructuredTriggerDraft("oracle", unstructured)).toBe(false);
expect(canEditStructuredTriggerDraft("oracle", missingSource)).toBe(false);
expect(canEditStructuredTriggerDraft(undefined, existing)).toBe(false);
expect(canEditStructuredTriggerDraft("mysql", existing)).toBe(true);
expect(canEditStructuredTriggerDraft("sqlserver", existing)).toBe(true);
@@ -80,6 +80,7 @@ export interface EditableStructureTrigger {
timing: string;
event: string;
statement: string;
enabled?: boolean;
original?: TriggerInfo;
markedForDrop: boolean;
}
@@ -1343,13 +1343,23 @@ export function createTriggerDrafts(triggers: TriggerInfo[]): EditableStructureT
timing: trigger.timing,
event: trigger.event,
statement: trigger.statement ?? "",
enabled: trigger.enabled ?? undefined,
original: trigger,
markedForDrop: false,
}));
}
export function canEditStructuredTriggerDraft(databaseType: DatabaseType | undefined, trigger: EditableStructureTrigger): boolean {
return !trigger.original || (databaseType !== undefined && databaseType !== "oracle");
if (!trigger.original) return true;
if (databaseType === undefined) return false;
// The Oracle driver keeps the `TRIGGER ...` declaration when the source has clauses (WHEN,
// REFERENCING, FOLLOWS, ...) that the timing/event/body form would drop on CREATE OR REPLACE.
return databaseType !== "oracle" || isStructuredOracleTriggerSource(trigger.original.statement);
}
function isStructuredOracleTriggerSource(statement: string | null | undefined): boolean {
const source = statement?.trim() ?? "";
return source !== "" && !/^(?:CREATE\s+(?:OR\s+REPLACE\s+)?)?(?:(?:EDITIONABLE|NONEDITIONABLE)\s+)?TRIGGER\b/i.test(source);
}
export function toColumnNames(columns: string[]): string {
@@ -489,6 +489,7 @@ fn trigger(name: &str, timing: &str, event: &str, statement: &str) -> EditableSt
timing: timing.to_string(),
event: event.to_string(),
statement: statement.to_string(),
enabled: None,
original: None,
marked_for_drop: false,
}
@@ -7451,8 +7452,140 @@ fn changed_postgres_trigger_remains_unsupported() {
assert_eq!(result.warnings, vec!["Editing triggers is not supported for postgres from this editor."]);
}
fn trigger_change_sql(
database_type: DatabaseType,
schema: &str,
table_name: &str,
triggers: Vec<EditableStructureTrigger>,
) -> TableStructureSqlResult {
build_table_structure_change_sql(TableStructureSqlOptions {
database_type: Some(database_type),
driver_profile: None,
schema: Some(schema.to_string()),
table_name: table_name.to_string(),
columns: Vec::new(),
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers,
table_comment: None,
original_table_comment: None,
mysql_engine: None,
mysql_auto_increment_value: None,
transwarp_create: None,
partitioned: false,
foreign_table: false,
is_gaussdb_m_mode: false,
table_collation: None,
primary_key_name: None,
})
}
fn existing_trigger(
name: &str,
timing: &str,
event: &str,
statement: &str,
enabled: Option<bool>,
) -> EditableStructureTrigger {
let mut existing = trigger(name, timing, event, statement);
existing.enabled = enabled;
existing.original = Some(TriggerInfo {
name: name.to_string(),
event: event.to_string(),
timing: timing.to_string(),
statement: Some(statement.to_string()),
enabled,
});
existing
}
#[test]
fn rejects_editing_existing_oracle_trigger_without_complete_source() {
fn oracle_trigger_enabled_toggle_emits_alter_trigger() {
let mut disable = existing_trigger("ORDERS_AUDIT", "AFTER EACH ROW", "INSERT", "BEGIN NULL; END;", Some(true));
disable.enabled = Some(false);
let mut enable = existing_trigger("ORDERS_CHECK", "BEFORE EACH ROW", "UPDATE", "BEGIN NULL; END;", Some(false));
enable.enabled = Some(true);
let result = trigger_change_sql(DatabaseType::Oracle, "APP", "ORDERS", vec![disable, enable]);
assert_eq!(
result.statements,
vec!["ALTER TRIGGER \"APP\".\"ORDERS_AUDIT\" DISABLE;", "ALTER TRIGGER \"APP\".\"ORDERS_CHECK\" ENABLE;",]
);
assert!(result.warnings.is_empty());
}
#[test]
fn oracle_trigger_body_edit_restores_disabled_state() {
let mut existing = existing_trigger("ORDERS_AUDIT", "AFTER EACH ROW", "INSERT", "BEGIN NULL; END;", Some(false));
existing.statement = "BEGIN\n DBMS_OUTPUT.PUT_LINE('x');\nEND;".to_string();
let result = trigger_change_sql(DatabaseType::Oracle, "APP", "ORDERS", vec![existing]);
assert_eq!(
result.statements,
vec![
"CREATE OR REPLACE TRIGGER \"APP\".\"ORDERS_AUDIT\" AFTER INSERT ON \"APP\".\"ORDERS\"\nFOR EACH ROW\nBEGIN\n DBMS_OUTPUT.PUT_LINE('x');\nEND;",
"ALTER TRIGGER \"APP\".\"ORDERS_AUDIT\" DISABLE;",
]
);
}
#[test]
fn rejects_editing_oracle_trigger_with_unstructured_source() {
let source =
"TRIGGER ORDERS_AUDIT\nBEFORE INSERT ON ORDERS\nFOR EACH ROW\nWHEN (NEW.STATUS = 'OPEN')\nBEGIN NULL; END;";
let mut edited = existing_trigger("ORDERS_AUDIT", "BEFORE EACH ROW", "INSERT", source, Some(true));
edited.event = "INSERT OR UPDATE".to_string();
let mut renamed = existing_trigger("ORDERS_CHECK", "BEFORE EACH ROW", "INSERT", source, Some(true));
renamed.name = "ORDERS_CHECK_V2".to_string();
let result = trigger_change_sql(DatabaseType::Oracle, "APP", "ORDERS", vec![edited, renamed]);
assert!(result.statements.is_empty(), "unexpected statements: {:?}", result.statements);
assert_eq!(
result.warnings,
vec![
"Editing existing Oracle trigger \"ORDERS_AUDIT\" requires its complete source definition.",
"Editing existing Oracle trigger \"ORDERS_CHECK\" requires its complete source definition.",
]
);
}
#[test]
fn oracle_trigger_with_unstructured_source_can_still_toggle_enabled() {
let source =
"TRIGGER ORDERS_AUDIT\nBEFORE INSERT ON ORDERS\nFOR EACH ROW\nWHEN (NEW.STATUS = 'OPEN')\nBEGIN NULL; END;";
let mut existing = existing_trigger("ORDERS_AUDIT", "BEFORE EACH ROW", "INSERT", source, Some(true));
existing.enabled = Some(false);
let result = trigger_change_sql(DatabaseType::Oracle, "APP", "ORDERS", vec![existing]);
assert_eq!(result.statements, vec!["ALTER TRIGGER \"APP\".\"ORDERS_AUDIT\" DISABLE;"]);
assert!(result.warnings.is_empty());
}
#[test]
fn sqlserver_trigger_enabled_toggle_emits_enable_disable_trigger() {
let mut disable = existing_trigger("orders_audit", "AFTER", "INSERT", "BEGIN SET NOCOUNT ON; END", Some(true));
disable.enabled = Some(false);
let mut enable = existing_trigger("orders_check", "AFTER", "UPDATE", "BEGIN SET NOCOUNT ON; END", Some(false));
enable.enabled = Some(true);
let result = trigger_change_sql(DatabaseType::SqlServer, "dbo", "orders", vec![disable, enable]);
assert_eq!(
result.statements,
vec![
"DISABLE TRIGGER [dbo].[orders_audit] ON [dbo].[orders];",
"ENABLE TRIGGER [dbo].[orders_check] ON [dbo].[orders];",
]
);
assert!(result.warnings.is_empty());
}
#[test]
fn allows_editing_existing_oracle_trigger() {
let mut existing = trigger(
"DBX_TRIGGER_4320_AUDIT",
"AFTER EACH ROW",
@@ -7488,11 +7621,13 @@ fn rejects_editing_existing_oracle_trigger_without_complete_source() {
primary_key_name: None,
});
assert!(result.statements.is_empty());
assert_eq!(
result.warnings,
vec!["Editing existing Oracle trigger \"DBX_TRIGGER_4320_AUDIT\" requires its complete source definition."]
result.statements,
vec![
"CREATE OR REPLACE TRIGGER \"APP\".\"DBX_TRIGGER_4320_AUDIT\" AFTER INSERT OR UPDATE OR DELETE ON \"APP\".\"DBX_TRIGGER_4320\"\nFOR EACH ROW\nDECLARE\n v_event VARCHAR2(10);\nBEGIN\n v_event := CASE WHEN INSERTING THEN 'INSERT' WHEN UPDATING THEN 'UPDATE' ELSE 'DELETE' END;\nEND;"
]
);
assert!(result.warnings.is_empty());
}
#[test]
@@ -46,44 +46,79 @@ fn build_trigger_sql_with_mode(
continue;
}
let mut needs_recreate = trigger.original.is_none();
let mut needs_alter_enabled = false;
if let Some(original) = &trigger.original {
if dialect == StructureDialect::Oracle {
if has_trigger_change(trigger, original) {
if !has_trigger_change(trigger, original) {
continue;
}
if has_trigger_body_change(trigger, original) {
if dialect == StructureDialect::Oracle && !is_structured_oracle_trigger_source(original) {
// The stored source carries clauses (WHEN, REFERENCING, FOLLOWS, ...) that the
// timing/event/body form cannot represent, so CREATE OR REPLACE would drop them.
warnings.push(format!(
"Editing existing Oracle trigger \"{}\" requires its complete source definition.",
original.name
));
continue;
}
continue;
}
if !has_trigger_change(trigger, original) {
continue;
}
// Oracle can replace a trigger in place; renames still require dropping the old object.
if dialect != StructureDialect::Oracle || clean(&trigger.name) != clean(&original.name) {
statements.push(drop_trigger_sql(dialect, options.schema.as_deref(), &original.name));
needs_recreate = true;
// Oracle can replace a trigger in place; renames still require dropping the old object.
if dialect != StructureDialect::Oracle || clean(&trigger.name) != clean(&original.name) {
statements.push(drop_trigger_sql(dialect, options.schema.as_deref(), &original.name));
}
} else if trigger.enabled.is_some() && trigger.enabled != original.enabled {
needs_alter_enabled = true;
}
}
if let Some(sql) = create_trigger_sql(
options.database_type,
dialect,
options.schema.as_deref(),
&table,
trigger,
warnings,
for_new_table,
) {
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))
{
if needs_recreate {
if let Some(sql) = create_trigger_sql(
options.database_type,
dialect,
options.schema.as_deref(),
&table,
trigger,
warnings,
for_new_table,
) {
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 {
let enabled = trigger
.enabled
.unwrap_or_else(|| trigger.original.as_ref().and_then(|o| o.enabled).unwrap_or(true));
if !enabled {
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};"));
}
} else if dialect == StructureDialect::Oracle {
let enabled = trigger
.enabled
.unwrap_or_else(|| trigger.original.as_ref().and_then(|o| o.enabled).unwrap_or(true));
if !enabled {
let schema = options.schema.as_deref();
let trigger_name = qualified_trigger_object_name(dialect, schema, &trigger.name);
statements.push(format!("ALTER TRIGGER {trigger_name} DISABLE;"));
}
}
}
} else if needs_alter_enabled {
if dialect == StructureDialect::Oracle {
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};"));
let state = if trigger.enabled == Some(true) { "ENABLE" } else { "DISABLE" };
statements.push(format!("ALTER TRIGGER {trigger_name} {state};"));
} else if dialect == StructureDialect::SqlServer {
let schema = options.schema.as_deref();
let trigger_name = qualified_trigger_object_name(dialect, schema, &trigger.name);
let state = if trigger.enabled == Some(true) { "ENABLE" } else { "DISABLE" };
statements.push(format!("{state} TRIGGER {trigger_name} ON {table};"));
}
}
}
@@ -104,6 +139,15 @@ fn has_trigger_edit(trigger: &EditableStructureTrigger) -> bool {
}
fn has_trigger_change(trigger: &EditableStructureTrigger, original: &TriggerInfo) -> bool {
let has_body_change = 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(""));
let has_enabled_change = trigger.enabled.is_some() && trigger.enabled != original.enabled;
has_body_change || has_enabled_change
}
fn has_trigger_body_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)
@@ -327,6 +371,13 @@ fn create_oracle_trigger_sql(
))
}
/// The driver strips the dictionary declaration only when the trigger is fully described by its
/// timing, event and body; otherwise the source still starts with `TRIGGER ...`.
fn is_structured_oracle_trigger_source(original: &TriggerInfo) -> bool {
let statement = original.statement.as_deref().unwrap_or("").trim();
!statement.is_empty() && !starts_with_trigger_declaration(statement)
}
/// Oracle triggers come back from the driver as `ALL_SOURCE` text. When the dictionary declaration
/// and the stored source cannot be aligned line by line — a trigger written on a single line — the
/// raw source is returned instead of the body, and appending it after our own `FOR EACH ROW`
@@ -211,6 +211,8 @@ pub struct EditableStructureTrigger {
#[serde(default)]
pub statement: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub original: Option<TriggerInfo>,
#[serde(default)]
pub marked_for_drop: bool,