Skip to content

feat: add support for trigger UPDATE OF columns (#342)#344

Merged
tianzhou merged 2 commits intomainfrom
fix/issue-342-trigger-update-of-columns
Mar 8, 2026
Merged

feat: add support for trigger UPDATE OF columns (#342)#344
tianzhou merged 2 commits intomainfrom
fix/issue-342-trigger-update-of-columns

Conversation

@tianzhou
Copy link
Contributor

@tianzhou tianzhou commented Mar 8, 2026

Summary

  • Triggers with column-specific UPDATE events (UPDATE OF col1, col2) were losing the column specification during schema inspection, causing pgschema plan to generate incorrect DDL (UPDATE instead of UPDATE OF email)
  • Added UpdateColumns field to the Trigger IR struct, extraction from pg_get_triggerdef() output in the inspector, and proper SQL generation in the diff engine

Fixes #342

Test plan

  • Extended add_trigger test case with a column-specific UPDATE OF salary trigger
  • Extended alter_trigger test case to use UPDATE OF salary
  • All 7 trigger diff tests pass
  • All 7 trigger integration tests pass
  • Run: PGSCHEMA_TEST_FILTER="create_trigger/" go test -v ./internal/diff -run TestDiffFromFiles
  • Run: PGSCHEMA_TEST_FILTER="create_trigger/" go test -v ./cmd -run TestPlanAndApply

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings March 8, 2026 02:44
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes schema inspection and diff generation for PostgreSQL triggers that use column-specific UPDATE events (UPDATE OF col1, col2), ensuring the column list is preserved and emitted in generated DDL.

Changes:

  • Added UpdateColumns to the trigger IR and included it in trigger equality checks.
  • Extracted UPDATE OF ... columns from pg_get_triggerdef() output during inspection.
  • Updated trigger SQL generation and updated trigger diff test fixtures to include UPDATE OF salary.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.

File Description
ir/ir.go Extends Trigger IR with UpdateColumns for column-specific UPDATE triggers.
ir/inspector.go Extracts UPDATE OF columns from pg_get_triggerdef() and populates IR.
internal/diff/trigger.go Compares UpdateColumns and generates UPDATE OF ... in trigger DDL.
testdata/diff/create_trigger/** Updates expected plans and SQL fixtures to cover UPDATE OF salary behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

ir/inspector.go Outdated

// Extract UPDATE OF columns from trigger definition
var updateColumns []string
if triggerRow.TriggerDefinition.Valid {
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

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

buildTriggers extracts UpdateColumns from the full trigger definition regardless of whether the trigger actually has an UPDATE event. If the definition contains the substring UPDATE OF later (e.g., inside a WHEN expression string literal), extractUpdateColumnsFromTriggerDef may return non-nil columns and cause spurious diffs even for INSERT/DELETE-only triggers. Consider only calling extractUpdateColumnsFromTriggerDef when events includes TriggerEventUpdate (decoded from tgtype).

Suggested change
if triggerRow.TriggerDefinition.Valid {
if triggerRow.TriggerDefinition.Valid && (events&TriggerEventUpdate) != 0 {

Copilot uses AI. Check for mistakes.
Comment on lines +1468 to +1506
// 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 != "" {
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

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

extractUpdateColumnsFromTriggerDef relies on naive substring searches/splits (e.g., locating the end via " ON " and splitting on commas). This breaks for valid trigger defs where UPDATE columns are quoted identifiers containing ON or , (both allowed inside double-quoted identifiers), and it can also be confused by string literals containing UPDATE OF ... ON .... Consider extracting update-column attnums from pg_trigger.tgattr (joining pg_attribute for names) instead of parsing pg_get_triggerdef(), or implement a small tokenizer that respects quotes/literals when scanning for ON and commas.

Copilot uses AI. Check for mistakes.
@greptile-apps
Copy link

greptile-apps bot commented Mar 8, 2026

Greptile Summary

This PR fixes a correctness bug where column-specific UPDATE OF col1, col2 trigger events were silently dropped during schema inspection, causing pgschema plan to emit bare UPDATE in the generated DDL instead of UPDATE OF <columns>. The fix is three-part: a new UpdateColumns []string field on the Trigger IR struct, a new extractUpdateColumnsFromTriggerDef parser in the inspector that extracts column names from pg_get_triggerdef() output, and updated SQL generation in generateTriggerSQLWithMode to emit UPDATE OF col1, col2 when the field is non-empty.

  • ir/ir.go — adds UpdateColumns []string with omitempty to the Trigger struct; straightforward and non-breaking.
  • ir/inspector.goextractUpdateColumnsFromTriggerDef correctly handles the main cases (UPDATE OF col1, col2, INSERT OR UPDATE OF col1, col2 OR DELETE, etc.), but the OR-split is performed on the original mixed-case colListStr rather than the already-computed uppercase copy; this is safe today because pg_get_triggerdef() always uppercases keywords, but is inconsistent with how the rest of the function works.
  • internal/diff/trigger.go — the new UpdateColumns equality check is order-sensitive (positional index comparison). Unlike normalizeTriggerEvents, which sorts events into a canonical order before comparison, there is no analogous sorting for UpdateColumns. PostgreSQL treats UPDATE OF a, b and UPDATE OF b, a identically, but the current code would consider them different and emit a spurious ALTER TRIGGER.
  • Test fixtures are updated consistently across add_trigger and alter_trigger cases.

Confidence Score: 4/5

  • Safe to merge for the core bug fix; two minor issues are worth addressing before the next release.
  • The core parsing and SQL-generation logic is correct and well-tested. Two issues lower the score slightly: (1) the OR-split in the extraction function operates on the original mixed-case string rather than the pre-computed uppercase copy, and (2) the UpdateColumns equality check is order-sensitive without a corresponding normalization step, which could produce unnecessary ALTER TRIGGERs when columns are listed in a different but semantically equivalent order.
  • ir/inspector.go (OR-split case sensitivity) and internal/diff/trigger.go (order-sensitive UpdateColumns comparison)

Important Files Changed

Filename Overview
internal/diff/trigger.go Adds UpdateColumns to triggersEqual comparison and SQL generation; comparison is order-sensitive without normalization, which could produce spurious ALTER TRIGGERs for semantically equivalent column orderings.
ir/inspector.go Adds extractUpdateColumnsFromTriggerDef to parse UPDATE OF column lists from pg_get_triggerdef() output; logic is correct for standard cases but the OR-split operates on the original mixed-case string instead of the pre-computed uppercase copy.
ir/ir.go Adds UpdateColumns []string field with omitempty JSON tag to the Trigger struct; clean, minimal change.
testdata/diff/create_trigger/add_trigger/new.sql Adds employees_salary_update_trigger with BEFORE UPDATE OF salary to the desired-state fixture; matches expected plan output.
testdata/diff/create_trigger/alter_trigger/new.sql Changes existing alter_trigger fixture from plain UPDATE to UPDATE OF salary; correctly exercises column-specific update detection.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["pg_get_triggerdef() output"] --> B["extractUpdateColumnsFromTriggerDef()"]
    B --> C{Contains\n'UPDATE OF '?}
    C -- No --> D["return nil\n(no column filter)"]
    C -- Yes --> E["Find ' ON ' boundary"]
    E --> F["Extract colListStr\n(original case)"]
    F --> G{Contains\n' OR '?}
    G -- Yes --> H["Split by ' OR '\nCheck for event keywords"]
    H --> I["Trim to first part\n(column list only)"]
    G -- No --> J["Split by ','"]
    I --> J
    J --> K["Trim each column name"]
    K --> L["UpdateColumns []string\nstored in Trigger IR"]

    L --> M["triggersEqual()"]
    M --> N{len match?}
    N -- No --> O["return false\n→ ALTER TRIGGER"]
    N -- Yes --> P["Order-sensitive\nindex comparison"]
    P --> Q{All equal?}
    Q -- No --> O
    Q -- Yes --> R["continue checks..."]

    L --> S["generateTriggerSQLWithMode()"]
    S --> T{UpdateColumns\nnon-empty?}
    T -- Yes --> U["'UPDATE OF col1, col2'"]
    T -- No --> V["'UPDATE'"]
    U --> W["Final SQL emitted"]
    V --> W
Loading

Last reviewed commit: 00bd4ca

Comment on lines +1481 to +1498
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
}
}
Copy link

Choose a reason for hiding this comment

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

OR-split uses original case, not the uppercased copy

colListStr is sliced from the original triggerDef (mixed case), but the split is done with " OR " which is an uppercase literal. Since pg_get_triggerdef() always returns SQL keywords in uppercase this works today, but the same function already computes upper for exactly this kind of case-insensitive matching. Using the raw string means any non-standard or future lowercase representation would silently skip the trim step and leave event keywords like delete in the column list.

Suggested change
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
}
}
parts := strings.Split(strings.ToUpper(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(parts[i])
isEvent := false
for _, kw := range eventKeywords {
if trimmed == kw || strings.HasPrefix(trimmed, kw+" ") {
isEvent = true
break
}
}
if isEvent {
// Slice colListStr at the corresponding byte offset instead
orIdx := strings.Index(strings.ToUpper(colListStr), " OR "+trimmed)
if orIdx != -1 {
colListStr = strings.TrimSpace(colListStr[:orIdx])
}
break
}
}
}

Comment on lines +49 to +57
// 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
}
}
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 produce spurious diffs

normalizeTriggerEvents sorts events into a canonical order to prevent false positives when the same set of events is listed differently in two IRs. UpdateColumns has no analogous normalization: if the same columns are listed in a different order (e.g., UPDATE OF a, b vs UPDATE OF b, a) — both semantically identical in PostgreSQL — triggersEqual returns false and an unnecessary ALTER TRIGGER is emitted.

Consider either sorting UpdateColumns in normalizeTrigger (same file as normalizeTriggerEvents), or doing a set-comparison here:

// Compare update columns (order-independent)
if len(old.UpdateColumns) != len(new.UpdateColumns) {
    return false
}
oldCols := make(map[string]struct{}, len(old.UpdateColumns))
for _, col := range old.UpdateColumns {
    oldCols[col] = struct{}{}
}
for _, col := range new.UpdateColumns {
    if _, ok := oldCols[col]; !ok {
        return false
    }
}

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>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@tianzhou tianzhou merged commit 5a900ec into main Mar 8, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request] Add support for trigger on specific fields

2 participants