diff --git a/ir/inspector.go b/ir/inspector.go index 45547eaf..af877585 100644 --- a/ir/inspector.go +++ b/ir/inspector.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "fmt" + "regexp" "sort" "strconv" "strings" @@ -1555,6 +1556,39 @@ func (i *Inspector) normalizeTriggerCondition(rawCondition string) string { } } + // Use pg_query to normalize the expression for consistent formatting + // This handles complex expressions, case normalization, etc. + normalizedCondition := normalizeExpressionWithPgQuery(condition) + if normalizedCondition != "" { + condition = normalizedCondition + } + + // Post-process to handle PostgreSQL's "NOT x IS DISTINCT FROM y" → "x IS NOT DISTINCT FROM y" + condition = i.normalizeNotDistinctFromPattern(condition) + + return condition +} + +// normalizeNotDistinctFromPattern converts "NOT x IS DISTINCT FROM y" to "x IS NOT DISTINCT FROM y" +// This handles the specific case where pg_query.Deparse converts PostgreSQL's internal +// "NOT (x IS DISTINCT FROM y)" representation to "NOT x IS DISTINCT FROM y" +func (i *Inspector) normalizeNotDistinctFromPattern(condition string) string { + // Use regex to match: "NOT IS DISTINCT FROM " + // and convert to: " IS NOT DISTINCT FROM " + + // Pattern explanation: + // ^NOT\s+ - starts with "NOT" followed by whitespace + // (.+?) - non-greedy capture of left expression + // \s+IS\s+DISTINCT\s+FROM\s+ - " IS DISTINCT FROM " with flexible whitespace + // (.+)$ - capture the right expression to end of string + re := regexp.MustCompile(`^NOT\s+(.+?)\s+IS\s+DISTINCT\s+FROM\s+(.+)$`) + + if matches := re.FindStringSubmatch(condition); matches != nil { + left := strings.TrimSpace(matches[1]) + right := strings.TrimSpace(matches[2]) + return fmt.Sprintf("%s IS NOT DISTINCT FROM %s", left, right) + } + return condition } diff --git a/ir/parser.go b/ir/parser.go index f601c588..130679f6 100644 --- a/ir/parser.go +++ b/ir/parser.go @@ -1411,6 +1411,20 @@ func (p *Parser) parseAExpr(expr *pg_query.A_Expr) string { return fmt.Sprintf("%s IN %s", left, right) } + // Handle DISTINCT FROM expressions + if expr.Kind == pg_query.A_Expr_Kind_AEXPR_DISTINCT { + left := p.extractExpressionText(expr.Lexpr) + right := p.extractExpressionText(expr.Rexpr) + return fmt.Sprintf("%s IS DISTINCT FROM %s", left, right) + } + + // Handle NOT DISTINCT FROM expressions + if expr.Kind == pg_query.A_Expr_Kind_AEXPR_NOT_DISTINCT { + left := p.extractExpressionText(expr.Lexpr) + right := p.extractExpressionText(expr.Rexpr) + return fmt.Sprintf("%s IS NOT DISTINCT FROM %s", left, right) + } + // Simplified implementation for basic expressions if len(expr.Name) > 0 { if str := expr.Name[0].GetString_(); str != nil { diff --git a/testdata/diff/create_trigger/add_trigger_when_distinct/diff.sql b/testdata/diff/create_trigger/add_trigger_when_distinct/diff.sql new file mode 100644 index 00000000..0a84e96c --- /dev/null +++ b/testdata/diff/create_trigger/add_trigger_when_distinct/diff.sql @@ -0,0 +1,11 @@ +CREATE OR REPLACE TRIGGER products_description_trigger + BEFORE UPDATE ON products + FOR EACH ROW + WHEN (NEW.description IS DISTINCT FROM OLD.description) + EXECUTE FUNCTION log_description_change(); + +CREATE OR REPLACE TRIGGER products_status_trigger + BEFORE UPDATE ON products + FOR EACH ROW + WHEN (NEW.status IS NOT DISTINCT FROM OLD.status) + EXECUTE FUNCTION skip_status_change(); \ No newline at end of file diff --git a/testdata/diff/create_trigger/add_trigger_when_distinct/new.sql b/testdata/diff/create_trigger/add_trigger_when_distinct/new.sql new file mode 100644 index 00000000..26ef3e01 --- /dev/null +++ b/testdata/diff/create_trigger/add_trigger_when_distinct/new.sql @@ -0,0 +1,34 @@ +CREATE TABLE public.products ( + id serial PRIMARY KEY, + name text NOT NULL, + description text, + status text, + updated_at timestamp DEFAULT CURRENT_TIMESTAMP +); + +CREATE OR REPLACE FUNCTION public.log_description_change() +RETURNS trigger AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION public.skip_status_change() +RETURNS trigger AS $$ +BEGIN + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER products_description_trigger + BEFORE UPDATE ON public.products + FOR EACH ROW + WHEN (NEW.description IS DISTINCT FROM OLD.description) + EXECUTE FUNCTION public.log_description_change(); + +CREATE TRIGGER products_status_trigger + BEFORE UPDATE ON public.products + FOR EACH ROW + WHEN (NEW.status IS NOT DISTINCT FROM OLD.status) + EXECUTE FUNCTION public.skip_status_change(); \ No newline at end of file diff --git a/testdata/diff/create_trigger/add_trigger_when_distinct/old.sql b/testdata/diff/create_trigger/add_trigger_when_distinct/old.sql new file mode 100644 index 00000000..db743e97 --- /dev/null +++ b/testdata/diff/create_trigger/add_trigger_when_distinct/old.sql @@ -0,0 +1,22 @@ +CREATE TABLE public.products ( + id serial PRIMARY KEY, + name text NOT NULL, + description text, + status text, + updated_at timestamp DEFAULT CURRENT_TIMESTAMP +); + +CREATE OR REPLACE FUNCTION public.log_description_change() +RETURNS trigger AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION public.skip_status_change() +RETURNS trigger AS $$ +BEGIN + RETURN NEW; +END; +$$ LANGUAGE plpgsql; \ No newline at end of file diff --git a/testdata/diff/create_trigger/add_trigger_when_distinct/plan.json b/testdata/diff/create_trigger/add_trigger_when_distinct/plan.json new file mode 100644 index 00000000..3217ca33 --- /dev/null +++ b/testdata/diff/create_trigger/add_trigger_when_distinct/plan.json @@ -0,0 +1,26 @@ +{ + "version": "1.0.0", + "pgschema_version": "1.1.1", + "created_at": "1970-01-01T00:00:00Z", + "source_fingerprint": { + "hash": "98db11096a7a86d2175ff6821924a2b64dddbf240681f23079c0d912d3ea22b5" + }, + "groups": [ + { + "steps": [ + { + "sql": "CREATE OR REPLACE TRIGGER products_description_trigger\n BEFORE UPDATE ON products\n FOR EACH ROW\n WHEN (NEW.description IS DISTINCT FROM OLD.description)\n EXECUTE FUNCTION log_description_change();", + "type": "table.trigger", + "operation": "create", + "path": "public.products.products_description_trigger" + }, + { + "sql": "CREATE OR REPLACE TRIGGER products_status_trigger\n BEFORE UPDATE ON products\n FOR EACH ROW\n WHEN (NEW.status IS NOT DISTINCT FROM OLD.status)\n EXECUTE FUNCTION skip_status_change();", + "type": "table.trigger", + "operation": "create", + "path": "public.products.products_status_trigger" + } + ] + } + ] +} diff --git a/testdata/diff/create_trigger/add_trigger_when_distinct/plan.sql b/testdata/diff/create_trigger/add_trigger_when_distinct/plan.sql new file mode 100644 index 00000000..cbb1043b --- /dev/null +++ b/testdata/diff/create_trigger/add_trigger_when_distinct/plan.sql @@ -0,0 +1,11 @@ +CREATE OR REPLACE TRIGGER products_description_trigger + BEFORE UPDATE ON products + FOR EACH ROW + WHEN (NEW.description IS DISTINCT FROM OLD.description) + EXECUTE FUNCTION log_description_change(); + +CREATE OR REPLACE TRIGGER products_status_trigger + BEFORE UPDATE ON products + FOR EACH ROW + WHEN (NEW.status IS NOT DISTINCT FROM OLD.status) + EXECUTE FUNCTION skip_status_change(); diff --git a/testdata/diff/create_trigger/add_trigger_when_distinct/plan.txt b/testdata/diff/create_trigger/add_trigger_when_distinct/plan.txt new file mode 100644 index 00000000..5207457f --- /dev/null +++ b/testdata/diff/create_trigger/add_trigger_when_distinct/plan.txt @@ -0,0 +1,24 @@ +Plan: 1 to modify. + +Summary by type: + tables: 1 to modify + +Tables: + ~ products + + products_description_trigger (trigger) + + products_status_trigger (trigger) + +DDL to be executed: +-------------------------------------------------- + +CREATE OR REPLACE TRIGGER products_description_trigger + BEFORE UPDATE ON products + FOR EACH ROW + WHEN (NEW.description IS DISTINCT FROM OLD.description) + EXECUTE FUNCTION log_description_change(); + +CREATE OR REPLACE TRIGGER products_status_trigger + BEFORE UPDATE ON products + FOR EACH ROW + WHEN (NEW.status IS NOT DISTINCT FROM OLD.status) + EXECUTE FUNCTION skip_status_change();