diff --git a/internal/diff/constraint.go b/internal/diff/constraint.go index 49358411..826903e9 100644 --- a/internal/diff/constraint.go +++ b/internal/diff/constraint.go @@ -62,6 +62,9 @@ func generateConstraintSQL(constraint *ir.Constraint, targetSchema string) strin result += " NOT VALID" } return result + case ir.ConstraintTypeExclusion: + // Use the full definition from pg_get_constraintdef() + return fmt.Sprintf("CONSTRAINT %s %s", ir.QuoteIdentifier(constraint.Name), constraint.ExclusionDefinition) default: return "" } @@ -79,6 +82,7 @@ func getInlineConstraintsForTable(table *ir.Table) []*ir.Constraint { var uniques []*ir.Constraint var foreignKeys []*ir.Constraint var checkConstraints []*ir.Constraint + var exclusionConstraints []*ir.Constraint for _, constraintName := range constraintNames { constraint := table.Constraints[constraintName] @@ -95,14 +99,17 @@ func getInlineConstraintsForTable(table *ir.Table) []*ir.Constraint { foreignKeys = append(foreignKeys, constraint) case ir.ConstraintTypeCheck: checkConstraints = append(checkConstraints, constraint) + case ir.ConstraintTypeExclusion: + exclusionConstraints = append(exclusionConstraints, constraint) } } - // Add constraints in order: PRIMARY KEY, UNIQUE, FOREIGN KEY, CHECK + // Add constraints in order: PRIMARY KEY, UNIQUE, FOREIGN KEY, CHECK, EXCLUDE inlineConstraints = append(inlineConstraints, primaryKeys...) inlineConstraints = append(inlineConstraints, uniques...) inlineConstraints = append(inlineConstraints, foreignKeys...) inlineConstraints = append(inlineConstraints, checkConstraints...) + inlineConstraints = append(inlineConstraints, exclusionConstraints...) return inlineConstraints } @@ -125,6 +132,9 @@ func constraintsEqual(old, new *ir.Constraint) bool { if old.CheckClause != new.CheckClause { return false } + if old.ExclusionDefinition != new.ExclusionDefinition { + return false + } // Foreign key specific properties (this is the key fix!) if old.DeleteRule != new.DeleteRule { @@ -153,8 +163,8 @@ func constraintsEqual(old, new *ir.Constraint) bool { return false } - // Compare columns (skip for CHECK constraints as column detection may differ between parser and inspector) - if old.Type != ir.ConstraintTypeCheck { + // Compare columns (skip for CHECK and EXCLUDE constraints as column detection may differ) + if old.Type != ir.ConstraintTypeCheck && old.Type != ir.ConstraintTypeExclusion { if len(old.Columns) != len(new.Columns) { return false } diff --git a/internal/diff/table.go b/internal/diff/table.go index 4c90aa3a..1a549501 100644 --- a/internal/diff/table.go +++ b/internal/diff/table.go @@ -887,6 +887,20 @@ func (td *tableDiff) generateAlterTableStatements(targetSchema string, collector CanRunInTransaction: true, } collector.collect(context, sql) + + case ir.ConstraintTypeExclusion: + tableName := getTableNameWithSchema(td.Table.Schema, td.Table.Name, targetSchema) + sql := fmt.Sprintf("ALTER TABLE %s\nADD CONSTRAINT %s %s;", + tableName, ir.QuoteIdentifier(constraint.Name), constraint.ExclusionDefinition) + + context := &diffContext{ + Type: DiffTypeTableConstraint, + Operation: DiffOperationCreate, + Path: fmt.Sprintf("%s.%s.%s", td.Table.Schema, td.Table.Name, constraint.Name), + Source: constraint, + CanRunInTransaction: true, + } + collector.collect(context, sql) } } @@ -946,6 +960,10 @@ func (td *tableDiff) generateAlterTableStatements(targetSchema string, collector } addSQL = fmt.Sprintf("ALTER TABLE %s\nADD CONSTRAINT %s PRIMARY KEY (%s);", tableName, ir.QuoteIdentifier(constraint.Name), strings.Join(columnNames, ", ")) + + case ir.ConstraintTypeExclusion: + addSQL = fmt.Sprintf("ALTER TABLE %s\nADD CONSTRAINT %s %s;", + tableName, ir.QuoteIdentifier(constraint.Name), constraint.ExclusionDefinition) } addContext := &diffContext{ diff --git a/ir/inspector.go b/ir/inspector.go index ac535738..97650649 100644 --- a/ir/inspector.go +++ b/ir/inspector.go @@ -463,6 +463,8 @@ func (i *Inspector) buildConstraints(ctx context.Context, schema *IR, targetSche cType = ConstraintTypeForeignKey case "CHECK": cType = ConstraintTypeCheck + case "EXCLUDE": + cType = ConstraintTypeExclusion default: continue // Skip unknown constraint types } @@ -509,6 +511,13 @@ func (i *Inspector) buildConstraints(ctx context.Context, schema *IR, targetSche } } + // Handle exclusion constraints + if cType == ConstraintTypeExclusion { + if exclDef := i.safeInterfaceToString(constraint.ExclusionDefinition); exclDef != "" && exclDef != "" { + c.ExclusionDefinition = exclDef + } + } + // Set validation state from database c.IsValid = constraint.IsValid diff --git a/ir/ir.go b/ir/ir.go index 50497c41..c6ec0842 100644 --- a/ir/ir.go +++ b/ir/ir.go @@ -204,21 +204,22 @@ type Sequence struct { // Constraint represents a table constraint type Constraint struct { - Schema string `json:"schema"` - Table string `json:"table"` - Name string `json:"name"` - Type ConstraintType `json:"type"` - Columns []*ConstraintColumn `json:"columns"` - ReferencedSchema string `json:"referenced_schema,omitempty"` - ReferencedTable string `json:"referenced_table,omitempty"` - ReferencedColumns []*ConstraintColumn `json:"referenced_columns,omitempty"` - CheckClause string `json:"check_clause,omitempty"` - DeleteRule string `json:"delete_rule,omitempty"` - UpdateRule string `json:"update_rule,omitempty"` - Deferrable bool `json:"deferrable,omitempty"` - InitiallyDeferred bool `json:"initially_deferred,omitempty"` - IsValid bool `json:"is_valid,omitempty"` - Comment string `json:"comment,omitempty"` + Schema string `json:"schema"` + Table string `json:"table"` + Name string `json:"name"` + Type ConstraintType `json:"type"` + Columns []*ConstraintColumn `json:"columns"` + ReferencedSchema string `json:"referenced_schema,omitempty"` + ReferencedTable string `json:"referenced_table,omitempty"` + ReferencedColumns []*ConstraintColumn `json:"referenced_columns,omitempty"` + CheckClause string `json:"check_clause,omitempty"` + ExclusionDefinition string `json:"exclusion_definition,omitempty"` // Full EXCLUDE definition from pg_get_constraintdef() + DeleteRule string `json:"delete_rule,omitempty"` + UpdateRule string `json:"update_rule,omitempty"` + Deferrable bool `json:"deferrable,omitempty"` + InitiallyDeferred bool `json:"initially_deferred,omitempty"` + IsValid bool `json:"is_valid,omitempty"` + Comment string `json:"comment,omitempty"` } // ConstraintColumn represents a column within a constraint with its position diff --git a/ir/normalize.go b/ir/normalize.go index 0d3a2409..0e1e4af0 100644 --- a/ir/normalize.go +++ b/ir/normalize.go @@ -877,10 +877,22 @@ func normalizeConstraint(constraint *Constraint) { return } - // Only normalize CHECK constraints - other constraint types are already consistent + // Only normalize CHECK and EXCLUDE constraints - other constraint types are already consistent if constraint.Type == ConstraintTypeCheck && constraint.CheckClause != "" { constraint.CheckClause = normalizeCheckClause(constraint.CheckClause) } + if constraint.Type == ConstraintTypeExclusion && constraint.ExclusionDefinition != "" { + constraint.ExclusionDefinition = normalizeExclusionDefinition(constraint.ExclusionDefinition) + } +} + +// normalizeExclusionDefinition normalizes EXCLUDE constraint definitions. +// +// pg_get_constraintdef() returns the full definition like: +// "EXCLUDE USING gist (range_col WITH &&)" +// We keep it as-is since both desired and current state come from pg_get_constraintdef(). +func normalizeExclusionDefinition(definition string) string { + return strings.TrimSpace(definition) } // normalizeCheckClause normalizes CHECK constraint expressions. diff --git a/ir/queries/queries.sql b/ir/queries/queries.sql index b5baeab5..a8433f24 100644 --- a/ir/queries/queries.sql +++ b/ir/queries/queries.sql @@ -312,6 +312,7 @@ SELECT COALESCE(fa.attname, '') AS foreign_column_name, COALESCE(fa.attnum, 0) AS foreign_ordinal_position, CASE WHEN c.contype = 'c' THEN pg_get_constraintdef(c.oid, true) ELSE NULL END AS check_clause, + CASE WHEN c.contype = 'x' THEN pg_get_constraintdef(c.oid, true) ELSE NULL END AS exclusion_definition, CASE c.confdeltype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' @@ -374,7 +375,7 @@ WITH index_base AS ( AND NOT EXISTS ( SELECT 1 FROM pg_constraint c WHERE c.conindid = idx.indexrelid - AND c.contype IN ('u', 'p') + AND c.contype IN ('u', 'p', 'x') ) AND n.nspname NOT IN ('information_schema', 'pg_catalog', 'pg_toast') AND n.nspname NOT LIKE 'pg_temp_%' @@ -460,7 +461,7 @@ WITH index_base AS ( AND NOT EXISTS ( SELECT 1 FROM pg_constraint c WHERE c.conindid = idx.indexrelid - AND c.contype IN ('u', 'p') + AND c.contype IN ('u', 'p', 'x') ) AND n.nspname = $1 ) @@ -887,6 +888,7 @@ SELECT COALESCE(fa.attname, '') AS foreign_column_name, COALESCE(fa.attnum, 0) AS foreign_ordinal_position, CASE WHEN c.contype = 'c' THEN pg_get_constraintdef(c.oid, true) ELSE NULL END AS check_clause, + CASE WHEN c.contype = 'x' THEN pg_get_constraintdef(c.oid, true) ELSE NULL END AS exclusion_definition, CASE c.confdeltype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' diff --git a/ir/queries/queries.sql.go b/ir/queries/queries.sql.go index 67fe7f2a..67e4d5fa 100644 --- a/ir/queries/queries.sql.go +++ b/ir/queries/queries.sql.go @@ -771,6 +771,7 @@ SELECT COALESCE(fa.attname, '') AS foreign_column_name, COALESCE(fa.attnum, 0) AS foreign_ordinal_position, CASE WHEN c.contype = 'c' THEN pg_get_constraintdef(c.oid, true) ELSE NULL END AS check_clause, + CASE WHEN c.contype = 'x' THEN pg_get_constraintdef(c.oid, true) ELSE NULL END AS exclusion_definition, CASE c.confdeltype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' @@ -815,6 +816,7 @@ type GetConstraintsRow struct { ForeignColumnName sql.NullString `db:"foreign_column_name" json:"foreign_column_name"` ForeignOrdinalPosition sql.NullInt32 `db:"foreign_ordinal_position" json:"foreign_ordinal_position"` CheckClause sql.NullString `db:"check_clause" json:"check_clause"` + ExclusionDefinition sql.NullString `db:"exclusion_definition" json:"exclusion_definition"` DeleteRule sql.NullString `db:"delete_rule" json:"delete_rule"` UpdateRule sql.NullString `db:"update_rule" json:"update_rule"` Deferrable bool `db:"deferrable" json:"deferrable"` @@ -844,6 +846,7 @@ func (q *Queries) GetConstraints(ctx context.Context) ([]GetConstraintsRow, erro &i.ForeignColumnName, &i.ForeignOrdinalPosition, &i.CheckClause, + &i.ExclusionDefinition, &i.DeleteRule, &i.UpdateRule, &i.Deferrable, @@ -883,6 +886,7 @@ SELECT COALESCE(fa.attname, '') AS foreign_column_name, COALESCE(fa.attnum, 0) AS foreign_ordinal_position, CASE WHEN c.contype = 'c' THEN pg_get_constraintdef(c.oid, true) ELSE NULL END AS check_clause, + CASE WHEN c.contype = 'x' THEN pg_get_constraintdef(c.oid, true) ELSE NULL END AS exclusion_definition, CASE c.confdeltype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' @@ -925,6 +929,7 @@ type GetConstraintsForSchemaRow struct { ForeignColumnName sql.NullString `db:"foreign_column_name" json:"foreign_column_name"` ForeignOrdinalPosition sql.NullInt32 `db:"foreign_ordinal_position" json:"foreign_ordinal_position"` CheckClause sql.NullString `db:"check_clause" json:"check_clause"` + ExclusionDefinition sql.NullString `db:"exclusion_definition" json:"exclusion_definition"` DeleteRule sql.NullString `db:"delete_rule" json:"delete_rule"` UpdateRule sql.NullString `db:"update_rule" json:"update_rule"` Deferrable bool `db:"deferrable" json:"deferrable"` @@ -954,6 +959,7 @@ func (q *Queries) GetConstraintsForSchema(ctx context.Context, dollar_1 sql.Null &i.ForeignColumnName, &i.ForeignOrdinalPosition, &i.CheckClause, + &i.ExclusionDefinition, &i.DeleteRule, &i.UpdateRule, &i.Deferrable, @@ -1564,7 +1570,7 @@ WITH index_base AS ( AND NOT EXISTS ( SELECT 1 FROM pg_constraint c WHERE c.conindid = idx.indexrelid - AND c.contype IN ('u', 'p') + AND c.contype IN ('u', 'p', 'x') ) AND n.nspname NOT IN ('information_schema', 'pg_catalog', 'pg_toast') AND n.nspname NOT LIKE 'pg_temp_%' @@ -1698,7 +1704,7 @@ WITH index_base AS ( AND NOT EXISTS ( SELECT 1 FROM pg_constraint c WHERE c.conindid = idx.indexrelid - AND c.contype IN ('u', 'p') + AND c.contype IN ('u', 'p', 'x') ) AND n.nspname = $1 ) diff --git a/testdata/diff/create_table/issue_281_exclude_constraint/diff.sql b/testdata/diff/create_table/issue_281_exclude_constraint/diff.sql new file mode 100644 index 00000000..f0af5d3a --- /dev/null +++ b/testdata/diff/create_table/issue_281_exclude_constraint/diff.sql @@ -0,0 +1,2 @@ +ALTER TABLE test_table +ADD CONSTRAINT excl_no_overlap EXCLUDE USING gist (range_col WITH &&); diff --git a/testdata/diff/create_table/issue_281_exclude_constraint/new.sql b/testdata/diff/create_table/issue_281_exclude_constraint/new.sql new file mode 100644 index 00000000..f478cac7 --- /dev/null +++ b/testdata/diff/create_table/issue_281_exclude_constraint/new.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS test_table ( + id integer, + range_col int4range NOT NULL, + CONSTRAINT test_table_pkey PRIMARY KEY (id), + CONSTRAINT excl_no_overlap EXCLUDE USING gist (range_col WITH &&) +); diff --git a/testdata/diff/create_table/issue_281_exclude_constraint/old.sql b/testdata/diff/create_table/issue_281_exclude_constraint/old.sql new file mode 100644 index 00000000..f66ca834 --- /dev/null +++ b/testdata/diff/create_table/issue_281_exclude_constraint/old.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS test_table ( + id integer, + range_col int4range NOT NULL, + CONSTRAINT test_table_pkey PRIMARY KEY (id) +); diff --git a/testdata/diff/create_table/issue_281_exclude_constraint/plan.json b/testdata/diff/create_table/issue_281_exclude_constraint/plan.json new file mode 100644 index 00000000..a54c721a --- /dev/null +++ b/testdata/diff/create_table/issue_281_exclude_constraint/plan.json @@ -0,0 +1,20 @@ +{ + "version": "1.0.0", + "pgschema_version": "1.7.0", + "created_at": "1970-01-01T00:00:00Z", + "source_fingerprint": { + "hash": "4fbb3d8c221ccc65b31f81678cb19ac44eccc901628273d00cc3e124a6ee6877" + }, + "groups": [ + { + "steps": [ + { + "sql": "ALTER TABLE test_table\nADD CONSTRAINT excl_no_overlap EXCLUDE USING gist (range_col WITH &&);", + "type": "table.constraint", + "operation": "create", + "path": "public.test_table.excl_no_overlap" + } + ] + } + ] +} diff --git a/testdata/diff/create_table/issue_281_exclude_constraint/plan.sql b/testdata/diff/create_table/issue_281_exclude_constraint/plan.sql new file mode 100644 index 00000000..f0af5d3a --- /dev/null +++ b/testdata/diff/create_table/issue_281_exclude_constraint/plan.sql @@ -0,0 +1,2 @@ +ALTER TABLE test_table +ADD CONSTRAINT excl_no_overlap EXCLUDE USING gist (range_col WITH &&); diff --git a/testdata/diff/create_table/issue_281_exclude_constraint/plan.txt b/testdata/diff/create_table/issue_281_exclude_constraint/plan.txt new file mode 100644 index 00000000..aa1c9cf3 --- /dev/null +++ b/testdata/diff/create_table/issue_281_exclude_constraint/plan.txt @@ -0,0 +1,14 @@ +Plan: 1 to modify. + +Summary by type: + tables: 1 to modify + +Tables: + ~ test_table + + excl_no_overlap (constraint) + +DDL to be executed: +-------------------------------------------------- + +ALTER TABLE test_table +ADD CONSTRAINT excl_no_overlap EXCLUDE USING gist (range_col WITH &&);