From b82a54372c3fdfdec32322dc66caba391e74ed3d Mon Sep 17 00:00:00 2001 From: tianzhou Date: Fri, 10 Oct 2025 20:06:00 +0800 Subject: [PATCH] feat: support CONSTRAINT TRIGGER --- internal/diff/table.go | 45 +++++++++++++++---- internal/diff/trigger.go | 35 +++++++++++++-- ir/ir.go | 21 +++++---- ir/parser.go | 19 ++++---- ir/queries/queries.sql | 8 ++-- ir/queries/queries.sql.go | 8 ++-- .../add_trigger_constraint/diff.sql | 6 +++ .../add_trigger_constraint/new.sql | 20 +++++++++ .../add_trigger_constraint/old.sql | 14 ++++++ .../add_trigger_constraint/plan.json | 20 +++++++++ .../add_trigger_constraint/plan.sql | 5 +++ .../add_trigger_constraint/plan.txt | 17 +++++++ 12 files changed, 181 insertions(+), 37 deletions(-) create mode 100644 testdata/diff/create_trigger/add_trigger_constraint/diff.sql create mode 100644 testdata/diff/create_trigger/add_trigger_constraint/new.sql create mode 100644 testdata/diff/create_trigger/add_trigger_constraint/old.sql create mode 100644 testdata/diff/create_trigger/add_trigger_constraint/plan.json create mode 100644 testdata/diff/create_trigger/add_trigger_constraint/plan.sql create mode 100644 testdata/diff/create_trigger/add_trigger_constraint/plan.txt diff --git a/internal/diff/table.go b/internal/diff/table.go index a7396e37..c220d783 100644 --- a/internal/diff/table.go +++ b/internal/diff/table.go @@ -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 { + 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) + 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 diff --git a/internal/diff/trigger.go b/internal/diff/trigger.go index 58a4c9f3..8e9ba34d 100644 --- a/internal/diff/trigger.go +++ b/internal/diff/trigger.go @@ -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 } @@ -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 @@ -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 != "" { diff --git a/ir/ir.go b/ir/ir.go index 48295b7f..53cec557 100644 --- a/ir/ir.go +++ b/ir/ir.go @@ -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 diff --git a/ir/parser.go b/ir/parser.go index de286fae..8cb08cc6 100644 --- a/ir/parser.go +++ b/ir/parser.go @@ -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 diff --git a/ir/queries/queries.sql b/ir/queries/queries.sql index 9e360192..c9f7a95e 100644 --- a/ir/queries/queries.sql +++ b/ir/queries/queries.sql @@ -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, @@ -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, diff --git a/ir/queries/queries.sql.go b/ir/queries/queries.sql.go index c9567f9a..147933cc 100644 --- a/ir/queries/queries.sql.go +++ b/ir/queries/queries.sql.go @@ -571,8 +571,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, @@ -683,8 +683,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, diff --git a/testdata/diff/create_trigger/add_trigger_constraint/diff.sql b/testdata/diff/create_trigger/add_trigger_constraint/diff.sql new file mode 100644 index 00000000..8b938fed --- /dev/null +++ b/testdata/diff/create_trigger/add_trigger_constraint/diff.sql @@ -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(); + diff --git a/testdata/diff/create_trigger/add_trigger_constraint/new.sql b/testdata/diff/create_trigger/add_trigger_constraint/new.sql new file mode 100644 index 00000000..49d1c663 --- /dev/null +++ b/testdata/diff/create_trigger/add_trigger_constraint/new.sql @@ -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(); diff --git a/testdata/diff/create_trigger/add_trigger_constraint/old.sql b/testdata/diff/create_trigger/add_trigger_constraint/old.sql new file mode 100644 index 00000000..12857f68 --- /dev/null +++ b/testdata/diff/create_trigger/add_trigger_constraint/old.sql @@ -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; diff --git a/testdata/diff/create_trigger/add_trigger_constraint/plan.json b/testdata/diff/create_trigger/add_trigger_constraint/plan.json new file mode 100644 index 00000000..dcad586f --- /dev/null +++ b/testdata/diff/create_trigger/add_trigger_constraint/plan.json @@ -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" + } + ] + } + ] +} diff --git a/testdata/diff/create_trigger/add_trigger_constraint/plan.sql b/testdata/diff/create_trigger/add_trigger_constraint/plan.sql new file mode 100644 index 00000000..44371a58 --- /dev/null +++ b/testdata/diff/create_trigger/add_trigger_constraint/plan.sql @@ -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(); diff --git a/testdata/diff/create_trigger/add_trigger_constraint/plan.txt b/testdata/diff/create_trigger/add_trigger_constraint/plan.txt new file mode 100644 index 00000000..55416602 --- /dev/null +++ b/testdata/diff/create_trigger/add_trigger_constraint/plan.txt @@ -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();