Skip to content

Commit 5a900ec

Browse files
tianzhouclaude
andauthored
feat: add support for trigger UPDATE OF columns (#342) (#344)
* feat: add support for trigger UPDATE OF columns (#342) Triggers with column-specific UPDATE events (e.g., UPDATE OF email) were losing the column specification during inspection, causing incorrect migration plans that would fire triggers on all updates instead of only on specified column changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: guard UPDATE OF column extraction with tgtype bitmask check Only extract UPDATE OF columns when the trigger actually has an UPDATE event, preventing false positives if the substring appears elsewhere. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c885408 commit 5a900ec

13 files changed

Lines changed: 116 additions & 8 deletions

File tree

internal/diff/trigger.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@ func triggersEqual(old, new *ir.Trigger) bool {
4646
}
4747
}
4848

49+
// Compare update columns
50+
if len(old.UpdateColumns) != len(new.UpdateColumns) {
51+
return false
52+
}
53+
for i, col := range old.UpdateColumns {
54+
if col != new.UpdateColumns[i] {
55+
return false
56+
}
57+
}
58+
4959
// Compare constraint trigger properties
5060
if old.IsConstraint != new.IsConstraint {
5161
return false
@@ -215,7 +225,11 @@ func generateTriggerSQLWithMode(trigger *ir.Trigger, targetSchema string) string
215225
for _, orderEvent := range eventOrder {
216226
for _, triggerEvent := range trigger.Events {
217227
if triggerEvent == orderEvent {
218-
events = append(events, string(triggerEvent))
228+
if triggerEvent == ir.TriggerEventUpdate && len(trigger.UpdateColumns) > 0 {
229+
events = append(events, "UPDATE OF "+strings.Join(trigger.UpdateColumns, ", "))
230+
} else {
231+
events = append(events, string(triggerEvent))
232+
}
219233
break
220234
}
221235
}

ir/inspector.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1452,6 +1452,65 @@ func extractWhenClauseFromTriggerDef(triggerDef string) string {
14521452
return strings.TrimSpace(triggerDef[start:end])
14531453
}
14541454

1455+
// extractUpdateColumnsFromTriggerDef extracts column names from UPDATE OF col1, col2 in a trigger definition
1456+
// returned by pg_get_triggerdef(). The format is:
1457+
// "CREATE TRIGGER name BEFORE INSERT OR UPDATE OF col1, col2 ON table ..."
1458+
func extractUpdateColumnsFromTriggerDef(triggerDef string) []string {
1459+
upper := strings.ToUpper(triggerDef)
1460+
updateOfIdx := strings.Index(upper, "UPDATE OF ")
1461+
if updateOfIdx == -1 {
1462+
return nil
1463+
}
1464+
1465+
// Start after "UPDATE OF "
1466+
start := updateOfIdx + len("UPDATE OF ")
1467+
1468+
// Find " ON " which terminates the event/column list
1469+
onIdx := strings.Index(upper[start:], " ON ")
1470+
if onIdx == -1 {
1471+
return nil
1472+
}
1473+
1474+
// Extract the column list substring
1475+
colListStr := strings.TrimSpace(triggerDef[start : start+onIdx])
1476+
1477+
// Handle potential " OR " separating additional events after the columns
1478+
// e.g., "UPDATE OF col1, col2 OR DELETE ON ..."
1479+
// We need to check if there's an OR followed by another event keyword
1480+
eventKeywords := []string{"INSERT", "UPDATE", "DELETE", "TRUNCATE"}
1481+
parts := strings.Split(colListStr, " OR ")
1482+
// Only keep the first part (column list); the rest would be other events
1483+
if len(parts) > 1 {
1484+
// Check if subsequent parts are event keywords
1485+
for i := 1; i < len(parts); i++ {
1486+
trimmed := strings.TrimSpace(strings.ToUpper(parts[i]))
1487+
isEvent := false
1488+
for _, kw := range eventKeywords {
1489+
if trimmed == kw || strings.HasPrefix(trimmed, kw+" ") {
1490+
isEvent = true
1491+
break
1492+
}
1493+
}
1494+
if isEvent {
1495+
colListStr = strings.TrimSpace(parts[0])
1496+
break
1497+
}
1498+
}
1499+
}
1500+
1501+
// Split by comma and trim each column name
1502+
rawCols := strings.Split(colListStr, ",")
1503+
var columns []string
1504+
for _, col := range rawCols {
1505+
col = strings.TrimSpace(col)
1506+
if col != "" {
1507+
columns = append(columns, col)
1508+
}
1509+
}
1510+
1511+
return columns
1512+
}
1513+
14551514
// extractFunctionCallFromTriggerDef extracts the function call (with arguments) from a trigger definition
14561515
// returned by pg_get_triggerdef(). The format is:
14571516
// "... EXECUTE FUNCTION function_name(arg1, arg2)"
@@ -1548,6 +1607,12 @@ func (i *Inspector) buildTriggers(ctx context.Context, schema *IR, targetSchema
15481607
condition = extractWhenClauseFromTriggerDef(triggerRow.TriggerDefinition.String)
15491608
}
15501609

1610+
// Extract UPDATE OF columns from trigger definition (only for triggers with UPDATE events)
1611+
var updateColumns []string
1612+
if triggerRow.TriggerDefinition.Valid && triggerRow.TriggerType&triggerTypeUpdate != 0 {
1613+
updateColumns = extractUpdateColumnsFromTriggerDef(triggerRow.TriggerDefinition.String)
1614+
}
1615+
15511616
// Extract transition table names
15521617
oldTable := ""
15531618
if triggerRow.OldTable.Valid {
@@ -1577,6 +1642,7 @@ func (i *Inspector) buildTriggers(ctx context.Context, schema *IR, targetSchema
15771642
Table: tableName,
15781643
Timing: timing,
15791644
Events: events,
1645+
UpdateColumns: updateColumns,
15801646
Level: level,
15811647
Function: functionCall,
15821648
Condition: condition,

ir/ir.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -278,8 +278,9 @@ type Trigger struct {
278278
Table string `json:"table"`
279279
Name string `json:"name"`
280280
Timing TriggerTiming `json:"timing"` // BEFORE, AFTER, INSTEAD OF
281-
Events []TriggerEvent `json:"events"` // INSERT, UPDATE, DELETE
282-
Level TriggerLevel `json:"level"` // ROW, STATEMENT
281+
Events []TriggerEvent `json:"events"` // INSERT, UPDATE, DELETE
282+
UpdateColumns []string `json:"update_columns,omitempty"` // Column names for UPDATE OF
283+
Level TriggerLevel `json:"level"` // ROW, STATEMENT
283284
Function string `json:"function"`
284285
Condition string `json:"condition,omitempty"` // WHEN condition
285286
Comment string `json:"comment,omitempty"`

testdata/diff/create_trigger/add_trigger/diff.sql

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ CREATE OR REPLACE TRIGGER employees_last_modified_trigger
88
FOR EACH ROW
99
EXECUTE FUNCTION update_last_modified();
1010

11+
CREATE OR REPLACE TRIGGER employees_salary_update_trigger
12+
BEFORE UPDATE OF salary ON employees
13+
FOR EACH ROW
14+
EXECUTE FUNCTION update_last_modified();
15+
1116
CREATE OR REPLACE TRIGGER employees_truncate_log_trigger
1217
AFTER TRUNCATE ON employees
1318
FOR EACH STATEMENT

testdata/diff/create_trigger/add_trigger/new.sql

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ CREATE TRIGGER employees_insert_timestamp_trigger
2323
FOR EACH ROW
2424
EXECUTE FUNCTION public.update_last_modified();
2525

26+
CREATE TRIGGER employees_salary_update_trigger
27+
BEFORE UPDATE OF salary ON public.employees
28+
FOR EACH ROW
29+
EXECUTE FUNCTION public.update_last_modified();
30+
2631
CREATE TRIGGER employees_truncate_log_trigger
2732
AFTER TRUNCATE ON public.employees
2833
FOR EACH STATEMENT

testdata/diff/create_trigger/add_trigger/plan.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@
2020
"operation": "create",
2121
"path": "public.employees.employees_last_modified_trigger"
2222
},
23+
{
24+
"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();",
25+
"type": "table.trigger",
26+
"operation": "create",
27+
"path": "public.employees.employees_salary_update_trigger"
28+
},
2329
{
2430
"sql": "CREATE OR REPLACE TRIGGER employees_truncate_log_trigger\n AFTER TRUNCATE ON employees\n FOR EACH STATEMENT\n EXECUTE FUNCTION update_last_modified();",
2531
"type": "table.trigger",

testdata/diff/create_trigger/add_trigger/plan.sql

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ CREATE OR REPLACE TRIGGER employees_last_modified_trigger
88
FOR EACH ROW
99
EXECUTE FUNCTION update_last_modified();
1010

11+
CREATE OR REPLACE TRIGGER employees_salary_update_trigger
12+
BEFORE UPDATE OF salary ON employees
13+
FOR EACH ROW
14+
EXECUTE FUNCTION update_last_modified();
15+
1116
CREATE OR REPLACE TRIGGER employees_truncate_log_trigger
1217
AFTER TRUNCATE ON employees
1318
FOR EACH STATEMENT

testdata/diff/create_trigger/add_trigger/plan.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Tables:
88
~ employees
99
+ employees_insert_timestamp_trigger (trigger)
1010
+ employees_last_modified_trigger (trigger)
11+
+ employees_salary_update_trigger (trigger)
1112
+ employees_truncate_log_trigger (trigger)
1213

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

31+
CREATE OR REPLACE TRIGGER employees_salary_update_trigger
32+
BEFORE UPDATE OF salary ON employees
33+
FOR EACH ROW
34+
EXECUTE FUNCTION update_last_modified();
35+
3036
CREATE OR REPLACE TRIGGER employees_truncate_log_trigger
3137
AFTER TRUNCATE ON employees
3238
FOR EACH STATEMENT
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
CREATE OR REPLACE TRIGGER employees_last_modified_trigger
2-
BEFORE INSERT OR UPDATE ON employees
2+
BEFORE INSERT OR UPDATE OF salary ON employees
33
FOR EACH ROW
44
WHEN (((NEW.salary IS NOT NULL)))
55
EXECUTE FUNCTION update_last_modified();

testdata/diff/create_trigger/alter_trigger/new.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ END;
1414
$$ LANGUAGE plpgsql;
1515

1616
CREATE TRIGGER employees_last_modified_trigger
17-
BEFORE INSERT OR UPDATE ON public.employees
17+
BEFORE INSERT OR UPDATE OF salary ON public.employees
1818
FOR EACH ROW
1919
WHEN (NEW.salary IS NOT NULL)
2020
EXECUTE FUNCTION public.update_last_modified();

0 commit comments

Comments
 (0)