diff --git a/agents/drivers/oracle-go/main.go b/agents/drivers/oracle-go/main.go index d287429a06..a4865e1c9c 100644 --- a/agents/drivers/oracle-go/main.go +++ b/agents/drivers/oracle-go/main.go @@ -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") diff --git a/agents/drivers/oracle-go/main_test.go b/agents/drivers/oracle-go/main_test.go index 49e25321e9..354cb17be8 100644 --- a/agents/drivers/oracle-go/main_test.go +++ b/agents/drivers/oracle-go/main_test.go @@ -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 { diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index 2ed176ea94..ea96082896 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -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( {{ event }} - - {{ trigger.original.enabled ? t("damengJobAdmin.enabled") : t("damengJobAdmin.disabled") }} + + + {{ trigger.enabled ? t("damengJobAdmin.enabled") : t("damengJobAdmin.disabled") }}