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
45 changes: 36 additions & 9 deletions internal/diff/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -971,17 +971,44 @@ func (td *tableDiff) generateAlterTableStatements(targetSchema string, collector

// Modify triggers - already sorted by the Diff operation
for _, triggerDiff := range td.ModifiedTriggers {
// Use CREATE OR REPLACE for modified triggers
sql := generateTriggerSQLWithMode(triggerDiff.New, targetSchema)
// Constraint triggers don't support CREATE OR REPLACE, so we need to DROP and CREATE
if triggerDiff.New.IsConstraint {
Copy link

Copilot AI Oct 10, 2025

Choose a reason for hiding this comment

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

This logic only checks if the new trigger is a constraint trigger, but should also handle the case where the old trigger was a constraint trigger but the new one isn't. Both scenarios require DROP and CREATE since constraint triggers don't support CREATE OR REPLACE.

Suggested change
if triggerDiff.New.IsConstraint {
if triggerDiff.New.IsConstraint || triggerDiff.Old.IsConstraint {

Copilot uses AI. Check for mistakes.
tableName := getTableNameWithSchema(td.Table.Schema, td.Table.Name, targetSchema)

context := &diffContext{
Type: DiffTypeTableTrigger,
Operation: DiffOperationAlter,
Path: fmt.Sprintf("%s.%s.%s", td.Table.Schema, td.Table.Name, triggerDiff.New.Name),
Source: triggerDiff,
CanRunInTransaction: true,
// Step 1: DROP the old trigger
dropSQL := fmt.Sprintf("DROP TRIGGER IF EXISTS %s ON %s;", triggerDiff.Old.Name, tableName)
Copy link

Copilot AI Oct 10, 2025

Choose a reason for hiding this comment

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

For constraint triggers, the DROP statement should use 'DROP TRIGGER' without 'IF EXISTS' since we know the trigger exists (it's being modified). Additionally, constraint triggers may need special handling in the DROP statement.

Suggested change
dropSQL := fmt.Sprintf("DROP TRIGGER IF EXISTS %s ON %s;", triggerDiff.Old.Name, tableName)
dropSQL := fmt.Sprintf("DROP TRIGGER %s ON %s;", triggerDiff.Old.Name, tableName)

Copilot uses AI. Check for mistakes.
dropContext := &diffContext{
Type: DiffTypeTableTrigger,
Operation: DiffOperationDrop,
Path: fmt.Sprintf("%s.%s.%s", td.Table.Schema, td.Table.Name, triggerDiff.Old.Name),
Source: triggerDiff.Old,
CanRunInTransaction: true,
}
collector.collect(dropContext, dropSQL)

// Step 2: CREATE the new constraint trigger
createSQL := generateTriggerSQLWithMode(triggerDiff.New, targetSchema)
createContext := &diffContext{
Type: DiffTypeTableTrigger,
Operation: DiffOperationCreate,
Path: fmt.Sprintf("%s.%s.%s", td.Table.Schema, td.Table.Name, triggerDiff.New.Name),
Source: triggerDiff.New,
CanRunInTransaction: true,
}
collector.collect(createContext, createSQL)
} else {
// Use CREATE OR REPLACE for regular triggers
sql := generateTriggerSQLWithMode(triggerDiff.New, targetSchema)

context := &diffContext{
Type: DiffTypeTableTrigger,
Operation: DiffOperationAlter,
Path: fmt.Sprintf("%s.%s.%s", td.Table.Schema, td.Table.Name, triggerDiff.New.Name),
Source: triggerDiff,
CanRunInTransaction: true,
}
collector.collect(context, sql)
}
collector.collect(context, sql)
}

// Modify policies - already sorted by the Diff operation
Expand Down
35 changes: 32 additions & 3 deletions internal/diff/trigger.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ func triggersEqual(old, new *ir.Trigger) bool {
}
}

// Compare constraint trigger properties
if old.IsConstraint != new.IsConstraint {
return false
}
if old.Deferrable != new.Deferrable {
return false
}
if old.InitiallyDeferred != new.InitiallyDeferred {
return false
}

return true
}

Expand Down Expand Up @@ -122,7 +133,7 @@ func generateCreateTriggersSQL(triggers []*ir.Trigger, targetSchema string, coll
}
}

// generateTriggerSQLWithMode generates CREATE [OR REPLACE] TRIGGER statement
// generateTriggerSQLWithMode generates CREATE [OR REPLACE] TRIGGER or CREATE CONSTRAINT TRIGGER statement
func generateTriggerSQLWithMode(trigger *ir.Trigger, targetSchema string) string {
// Build event list in standard order: INSERT, UPDATE, DELETE, TRUNCATE
var events []string
Expand All @@ -141,8 +152,26 @@ func generateTriggerSQLWithMode(trigger *ir.Trigger, targetSchema string) string
tableName := qualifyEntityName(trigger.Schema, trigger.Table, targetSchema)

// Build the trigger statement with proper formatting
stmt := fmt.Sprintf("CREATE OR REPLACE TRIGGER %s\n %s %s ON %s\n FOR EACH %s",
trigger.Name, trigger.Timing, eventList, tableName, trigger.Level)
// Use CREATE CONSTRAINT TRIGGER for constraint triggers (cannot use OR REPLACE)
var stmt string
if trigger.IsConstraint {
stmt = fmt.Sprintf("CREATE CONSTRAINT TRIGGER %s\n %s %s ON %s",
trigger.Name, trigger.Timing, eventList, tableName)

// Add deferrable clause for constraint triggers
if trigger.Deferrable {
if trigger.InitiallyDeferred {
stmt += "\n DEFERRABLE INITIALLY DEFERRED"
} else {
stmt += "\n DEFERRABLE INITIALLY IMMEDIATE"
}
}

stmt += fmt.Sprintf("\n FOR EACH %s", trigger.Level)
} else {
stmt = fmt.Sprintf("CREATE OR REPLACE TRIGGER %s\n %s %s ON %s\n FOR EACH %s",
trigger.Name, trigger.Timing, eventList, tableName, trigger.Level)
}

// Add WHEN clause if present
if trigger.Condition != "" {
Expand Down
21 changes: 12 additions & 9 deletions ir/ir.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,15 +227,18 @@ const (

// Trigger represents a database trigger
type Trigger struct {
Schema string `json:"schema"`
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
Function string `json:"function"`
Condition string `json:"condition,omitempty"` // WHEN condition
Comment string `json:"comment,omitempty"`
Schema string `json:"schema"`
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
Function string `json:"function"`
Condition string `json:"condition,omitempty"` // WHEN condition
Comment string `json:"comment,omitempty"`
IsConstraint bool `json:"is_constraint,omitempty"` // Whether this is a constraint trigger
Deferrable bool `json:"deferrable,omitempty"` // Can be deferred until end of transaction
InitiallyDeferred bool `json:"initially_deferred,omitempty"` // Whether deferred by default
}

// TriggerTiming represents the timing of trigger execution
Expand Down
19 changes: 11 additions & 8 deletions ir/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -3239,14 +3239,17 @@ func (p *Parser) parseCreateTrigger(triggerStmt *pg_query.CreateTrigStmt) error

// Create trigger
trigger := &Trigger{
Schema: schemaName,
Table: tableName,
Name: triggerStmt.Trigname,
Timing: timing,
Events: events,
Level: level,
Function: function,
Condition: condition,
Schema: schemaName,
Table: tableName,
Name: triggerStmt.Trigname,
Timing: timing,
Events: events,
Level: level,
Function: function,
Condition: condition,
IsConstraint: triggerStmt.Isconstraint,
Deferrable: triggerStmt.Deferrable,
InitiallyDeferred: triggerStmt.Initdeferred,
}

// Add trigger to table only
Expand Down
8 changes: 4 additions & 4 deletions ir/queries/queries.sql
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,8 @@ SELECT
WHEN 'x' THEN 'EXCLUDE'
ELSE 'UNKNOWN'
END AS constraint_type,
a.attname AS column_name,
a.attnum AS ordinal_position,
COALESCE(a.attname, '') AS column_name,
COALESCE(a.attnum, 0) AS ordinal_position,
COALESCE(fn.nspname, '') AS foreign_table_schema,
COALESCE(fcl.relname, '') AS foreign_table_name,
COALESCE(fa.attname, '') AS foreign_column_name,
Expand Down Expand Up @@ -663,8 +663,8 @@ SELECT
WHEN 'x' THEN 'EXCLUDE'
ELSE 'UNKNOWN'
END AS constraint_type,
a.attname AS column_name,
a.attnum AS ordinal_position,
COALESCE(a.attname, '') AS column_name,
COALESCE(a.attnum, 0) AS ordinal_position,
COALESCE(fn.nspname, '') AS foreign_table_schema,
COALESCE(fcl.relname, '') AS foreign_table_name,
COALESCE(fa.attname, '') AS foreign_column_name,
Expand Down
8 changes: 4 additions & 4 deletions ir/queries/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions testdata/diff/create_trigger/add_trigger_constraint/diff.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
CREATE CONSTRAINT TRIGGER prevent_code_update_trigger
AFTER UPDATE ON products
DEFERRABLE INITIALLY IMMEDIATE
FOR EACH ROW
EXECUTE FUNCTION prevent_code_update();

20 changes: 20 additions & 0 deletions testdata/diff/create_trigger/add_trigger_constraint/new.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
CREATE TABLE public.products (
id integer PRIMARY KEY,
code text NOT NULL
);

CREATE OR REPLACE FUNCTION public.prevent_code_update()
RETURNS trigger AS $$
BEGIN
IF OLD.code IS DISTINCT FROM NEW.code THEN
RAISE EXCEPTION 'Product code cannot be updated';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE CONSTRAINT TRIGGER prevent_code_update_trigger
AFTER UPDATE ON public.products
DEFERRABLE INITIALLY IMMEDIATE
FOR EACH ROW
EXECUTE FUNCTION public.prevent_code_update();
14 changes: 14 additions & 0 deletions testdata/diff/create_trigger/add_trigger_constraint/old.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
CREATE TABLE public.products (
id integer PRIMARY KEY,
code text NOT NULL
);

CREATE OR REPLACE FUNCTION public.prevent_code_update()
RETURNS trigger AS $$
BEGIN
IF OLD.code IS DISTINCT FROM NEW.code THEN
RAISE EXCEPTION 'Product code cannot be updated';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
20 changes: 20 additions & 0 deletions testdata/diff/create_trigger/add_trigger_constraint/plan.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"version": "1.0.0",
"pgschema_version": "1.2.1",
"created_at": "1970-01-01T00:00:00Z",
"source_fingerprint": {
"hash": "08f4c7258568484a7571fb1a332a97279e6dcb5336ddbbf021d777cc1b73730d"
},
"groups": [
{
"steps": [
{
"sql": "CREATE CONSTRAINT TRIGGER prevent_code_update_trigger\n AFTER UPDATE ON products\n DEFERRABLE INITIALLY IMMEDIATE\n FOR EACH ROW\n EXECUTE FUNCTION prevent_code_update();",
"type": "table.trigger",
"operation": "create",
"path": "public.products.prevent_code_update_trigger"
}
]
}
]
}
5 changes: 5 additions & 0 deletions testdata/diff/create_trigger/add_trigger_constraint/plan.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
CREATE CONSTRAINT TRIGGER prevent_code_update_trigger
AFTER UPDATE ON products
DEFERRABLE INITIALLY IMMEDIATE
FOR EACH ROW
EXECUTE FUNCTION prevent_code_update();
17 changes: 17 additions & 0 deletions testdata/diff/create_trigger/add_trigger_constraint/plan.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
Plan: 1 to modify.

Summary by type:
tables: 1 to modify

Tables:
~ products
+ prevent_code_update_trigger (trigger)

DDL to be executed:
--------------------------------------------------

CREATE CONSTRAINT TRIGGER prevent_code_update_trigger
AFTER UPDATE ON products
DEFERRABLE INITIALLY IMMEDIATE
FOR EACH ROW
EXECUTE FUNCTION prevent_code_update();