Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions cmd/ignore_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1130,6 +1130,40 @@ ALTER DEFAULT PRIVILEGES GRANT ALL ON TABLES TO deploy_bot;
t.Error("Plan should not include changes for admin_role (ignored)")
}
})

// Clean up cluster-level objects (roles, default privileges) from sharedEmbeddedPG.
// The plan subtest applies SQL with CREATE ROLE and ALTER DEFAULT PRIVILEGES to the
// shared embedded PG instance. These are cluster-level objects that persist after the
// temp schema is dropped, contaminating subsequent tests (e.g., TestPlanAndApply).
cleanupSharedEmbeddedPG(t)
}

// cleanupSharedEmbeddedPG removes cluster-level objects (roles, default privileges)
// that were created in sharedEmbeddedPG by privilege tests.
func cleanupSharedEmbeddedPG(t *testing.T) {
t.Helper()

sharedConn, _, _, _, _, _ := testutil.ConnectToPostgres(t, sharedEmbeddedPG)
defer sharedConn.Close()

// Must clean up in order: revoke default privileges, revoke object privileges, then drop roles.
// Each statement runs independently since some roles may not exist.
cleanupStatements := []string{
"ALTER DEFAULT PRIVILEGES REVOKE ALL ON TABLES FROM app_reader",
"ALTER DEFAULT PRIVILEGES REVOKE ALL ON TABLES FROM deploy_bot",
"REASSIGN OWNED BY app_reader TO testuser",
"DROP OWNED BY app_reader",
"REASSIGN OWNED BY deploy_bot TO testuser",
"DROP OWNED BY deploy_bot",
"REASSIGN OWNED BY admin_role TO testuser",
"DROP OWNED BY admin_role",
"DROP ROLE IF EXISTS app_reader",
"DROP ROLE IF EXISTS deploy_bot",
"DROP ROLE IF EXISTS admin_role",
}
for _, stmt := range cleanupStatements {
sharedConn.Exec(stmt) // Ignore errors; some roles may not exist
}
}

// verifyPlanOutput checks that plan output excludes ignored objects
Expand Down
16 changes: 15 additions & 1 deletion internal/diff/trigger.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ func triggersEqual(old, new *ir.Trigger) bool {
}
}

// Compare update columns
if len(old.UpdateColumns) != len(new.UpdateColumns) {
return false
}
for i, col := range old.UpdateColumns {
if col != new.UpdateColumns[i] {
return false
}
}
Comment on lines +49 to +57
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Order-sensitive UpdateColumns comparison may cause spurious migrations

UpdateColumns are compared element-by-element in the order they appear. PostgreSQL treats UPDATE OF a, b and UPDATE OF b, a as semantically identical (the trigger fires when any listed column is updated), but this comparison would treat them as different and generate an unnecessary CREATE OR REPLACE TRIGGER. If a user ever reorders columns in their schema file, they'll see unexpected plan output.

Consider sorting both slices before comparing:

Suggested change
// Compare update columns
if len(old.UpdateColumns) != len(new.UpdateColumns) {
return false
}
for i, col := range old.UpdateColumns {
if col != new.UpdateColumns[i] {
return false
}
}
// Compare update columns (order-independent, as PostgreSQL treats them as a set)
if len(old.UpdateColumns) != len(new.UpdateColumns) {
return false
}
oldCols := append([]string(nil), old.UpdateColumns...)
newCols := append([]string(nil), new.UpdateColumns...)
sort.Strings(oldCols)
sort.Strings(newCols)
for i, col := range oldCols {
if col != newCols[i] {
return false
}
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not an issue here. Both desired and current state go through database inspection (pg_get_triggerdef()), which normalizes column order by attnum. pgschema doesn't parse raw SQL — both sides are always inspector-derived, so the column order is deterministic and consistent.

Adding a sort would be defensive but unnecessary given the architecture.


// Compare constraint trigger properties
if old.IsConstraint != new.IsConstraint {
return false
Expand Down Expand Up @@ -215,7 +225,11 @@ func generateTriggerSQLWithMode(trigger *ir.Trigger, targetSchema string) string
for _, orderEvent := range eventOrder {
for _, triggerEvent := range trigger.Events {
if triggerEvent == orderEvent {
events = append(events, string(triggerEvent))
if triggerEvent == ir.TriggerEventUpdate && len(trigger.UpdateColumns) > 0 {
events = append(events, "UPDATE OF "+strings.Join(trigger.UpdateColumns, ", "))
} else {
events = append(events, string(triggerEvent))
}
break
}
}
Expand Down
66 changes: 66 additions & 0 deletions ir/inspector.go
Original file line number Diff line number Diff line change
Expand Up @@ -1452,6 +1452,65 @@ func extractWhenClauseFromTriggerDef(triggerDef string) string {
return strings.TrimSpace(triggerDef[start:end])
}

// extractUpdateColumnsFromTriggerDef extracts column names from UPDATE OF col1, col2 in a trigger definition
// returned by pg_get_triggerdef(). The format is:
// "CREATE TRIGGER name BEFORE INSERT OR UPDATE OF col1, col2 ON table ..."
func extractUpdateColumnsFromTriggerDef(triggerDef string) []string {
upper := strings.ToUpper(triggerDef)
updateOfIdx := strings.Index(upper, "UPDATE OF ")
if updateOfIdx == -1 {
return nil
}

// Start after "UPDATE OF "
start := updateOfIdx + len("UPDATE OF ")

// Find " ON " which terminates the event/column list
onIdx := strings.Index(upper[start:], " ON ")
if onIdx == -1 {
return nil
}

// Extract the column list substring
colListStr := strings.TrimSpace(triggerDef[start : start+onIdx])

// Handle potential " OR " separating additional events after the columns
// e.g., "UPDATE OF col1, col2 OR DELETE ON ..."
// We need to check if there's an OR followed by another event keyword
eventKeywords := []string{"INSERT", "UPDATE", "DELETE", "TRUNCATE"}
parts := strings.Split(colListStr, " OR ")
// Only keep the first part (column list); the rest would be other events
if len(parts) > 1 {
// Check if subsequent parts are event keywords
for i := 1; i < len(parts); i++ {
trimmed := strings.TrimSpace(strings.ToUpper(parts[i]))
isEvent := false
for _, kw := range eventKeywords {
if trimmed == kw || strings.HasPrefix(trimmed, kw+" ") {
isEvent = true
break
}
}
if isEvent {
colListStr = strings.TrimSpace(parts[0])
break
}
}
}

// Split by comma and trim each column name
rawCols := strings.Split(colListStr, ",")
var columns []string
for _, col := range rawCols {
col = strings.TrimSpace(col)
if col != "" {
columns = append(columns, col)
}
}

return columns
}

// extractFunctionCallFromTriggerDef extracts the function call (with arguments) from a trigger definition
// returned by pg_get_triggerdef(). The format is:
// "... EXECUTE FUNCTION function_name(arg1, arg2)"
Expand Down Expand Up @@ -1548,6 +1607,12 @@ func (i *Inspector) buildTriggers(ctx context.Context, schema *IR, targetSchema
condition = extractWhenClauseFromTriggerDef(triggerRow.TriggerDefinition.String)
}

// Extract UPDATE OF columns from trigger definition (only for triggers with UPDATE events)
var updateColumns []string
if triggerRow.TriggerDefinition.Valid && triggerRow.TriggerType&triggerTypeUpdate != 0 {
updateColumns = extractUpdateColumnsFromTriggerDef(triggerRow.TriggerDefinition.String)
}

// Extract transition table names
oldTable := ""
if triggerRow.OldTable.Valid {
Expand Down Expand Up @@ -1577,6 +1642,7 @@ func (i *Inspector) buildTriggers(ctx context.Context, schema *IR, targetSchema
Table: tableName,
Timing: timing,
Events: events,
UpdateColumns: updateColumns,
Level: level,
Function: functionCall,
Condition: condition,
Expand Down
5 changes: 3 additions & 2 deletions ir/ir.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,8 +278,9 @@ type Trigger struct {
Table string `json:"table"`
Name string `json:"name"`
Timing TriggerTiming `json:"timing"` // BEFORE, AFTER, INSTEAD OF
Events []TriggerEvent `json:"events"` // INSERT, UPDATE, DELETE
Level TriggerLevel `json:"level"` // ROW, STATEMENT
Events []TriggerEvent `json:"events"` // INSERT, UPDATE, DELETE
UpdateColumns []string `json:"update_columns,omitempty"` // Column names for UPDATE OF
Level TriggerLevel `json:"level"` // ROW, STATEMENT
Function string `json:"function"`
Condition string `json:"condition,omitempty"` // WHEN condition
Comment string `json:"comment,omitempty"`
Expand Down
5 changes: 5 additions & 0 deletions testdata/diff/create_trigger/add_trigger/diff.sql
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ CREATE OR REPLACE TRIGGER employees_last_modified_trigger
FOR EACH ROW
EXECUTE FUNCTION update_last_modified();

CREATE OR REPLACE TRIGGER employees_salary_update_trigger
BEFORE UPDATE OF salary ON employees
FOR EACH ROW
EXECUTE FUNCTION update_last_modified();

CREATE OR REPLACE TRIGGER employees_truncate_log_trigger
AFTER TRUNCATE ON employees
FOR EACH STATEMENT
Expand Down
5 changes: 5 additions & 0 deletions testdata/diff/create_trigger/add_trigger/new.sql
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ CREATE TRIGGER employees_insert_timestamp_trigger
FOR EACH ROW
EXECUTE FUNCTION public.update_last_modified();

CREATE TRIGGER employees_salary_update_trigger
BEFORE UPDATE OF salary ON public.employees
FOR EACH ROW
EXECUTE FUNCTION public.update_last_modified();

CREATE TRIGGER employees_truncate_log_trigger
AFTER TRUNCATE ON public.employees
FOR EACH STATEMENT
Expand Down
6 changes: 6 additions & 0 deletions testdata/diff/create_trigger/add_trigger/plan.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@
"operation": "create",
"path": "public.employees.employees_last_modified_trigger"
},
{
"sql": "CREATE OR REPLACE TRIGGER employees_salary_update_trigger\n BEFORE UPDATE OF salary ON employees\n FOR EACH ROW\n EXECUTE FUNCTION update_last_modified();",
"type": "table.trigger",
"operation": "create",
"path": "public.employees.employees_salary_update_trigger"
},
{
"sql": "CREATE OR REPLACE TRIGGER employees_truncate_log_trigger\n AFTER TRUNCATE ON employees\n FOR EACH STATEMENT\n EXECUTE FUNCTION update_last_modified();",
"type": "table.trigger",
Expand Down
5 changes: 5 additions & 0 deletions testdata/diff/create_trigger/add_trigger/plan.sql
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ CREATE OR REPLACE TRIGGER employees_last_modified_trigger
FOR EACH ROW
EXECUTE FUNCTION update_last_modified();

CREATE OR REPLACE TRIGGER employees_salary_update_trigger
BEFORE UPDATE OF salary ON employees
FOR EACH ROW
EXECUTE FUNCTION update_last_modified();

CREATE OR REPLACE TRIGGER employees_truncate_log_trigger
AFTER TRUNCATE ON employees
FOR EACH STATEMENT
Expand Down
6 changes: 6 additions & 0 deletions testdata/diff/create_trigger/add_trigger/plan.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Tables:
~ employees
+ employees_insert_timestamp_trigger (trigger)
+ employees_last_modified_trigger (trigger)
+ employees_salary_update_trigger (trigger)
+ employees_truncate_log_trigger (trigger)

Views:
Expand All @@ -27,6 +28,11 @@ CREATE OR REPLACE TRIGGER employees_last_modified_trigger
FOR EACH ROW
EXECUTE FUNCTION update_last_modified();

CREATE OR REPLACE TRIGGER employees_salary_update_trigger
BEFORE UPDATE OF salary ON employees
FOR EACH ROW
EXECUTE FUNCTION update_last_modified();

CREATE OR REPLACE TRIGGER employees_truncate_log_trigger
AFTER TRUNCATE ON employees
FOR EACH STATEMENT
Expand Down
2 changes: 1 addition & 1 deletion testdata/diff/create_trigger/alter_trigger/diff.sql
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
CREATE OR REPLACE TRIGGER employees_last_modified_trigger
BEFORE INSERT OR UPDATE ON employees
BEFORE INSERT OR UPDATE OF salary ON employees
FOR EACH ROW
WHEN (((NEW.salary IS NOT NULL)))
EXECUTE FUNCTION update_last_modified();
2 changes: 1 addition & 1 deletion testdata/diff/create_trigger/alter_trigger/new.sql
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER employees_last_modified_trigger
BEFORE INSERT OR UPDATE ON public.employees
BEFORE INSERT OR UPDATE OF salary ON public.employees
FOR EACH ROW
WHEN (NEW.salary IS NOT NULL)
EXECUTE FUNCTION public.update_last_modified();
2 changes: 1 addition & 1 deletion testdata/diff/create_trigger/alter_trigger/plan.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
{
"steps": [
{
"sql": "CREATE OR REPLACE TRIGGER employees_last_modified_trigger\n BEFORE INSERT OR UPDATE ON employees\n FOR EACH ROW\n WHEN (((NEW.salary IS NOT NULL)))\n EXECUTE FUNCTION update_last_modified();",
"sql": "CREATE OR REPLACE TRIGGER employees_last_modified_trigger\n BEFORE INSERT OR UPDATE OF salary ON employees\n FOR EACH ROW\n WHEN (((NEW.salary IS NOT NULL)))\n EXECUTE FUNCTION update_last_modified();",
"type": "table.trigger",
"operation": "alter",
"path": "public.employees.employees_last_modified_trigger"
Expand Down
2 changes: 1 addition & 1 deletion testdata/diff/create_trigger/alter_trigger/plan.sql
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
CREATE OR REPLACE TRIGGER employees_last_modified_trigger
BEFORE INSERT OR UPDATE ON employees
BEFORE INSERT OR UPDATE OF salary ON employees
FOR EACH ROW
WHEN (((NEW.salary IS NOT NULL)))
EXECUTE FUNCTION update_last_modified();
2 changes: 1 addition & 1 deletion testdata/diff/create_trigger/alter_trigger/plan.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ DDL to be executed:
--------------------------------------------------

CREATE OR REPLACE TRIGGER employees_last_modified_trigger
BEFORE INSERT OR UPDATE ON employees
BEFORE INSERT OR UPDATE OF salary ON employees
FOR EACH ROW
WHEN (((NEW.salary IS NOT NULL)))
EXECUTE FUNCTION update_last_modified();