mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(oracle): add trigger operations in structure edit
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user