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
16 changes: 13 additions & 3 deletions internal/diff/constraint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +66 to +67
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

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

The EXCLUDE constraint generation does not handle the NOT VALID state. While pg_get_constraintdef() returns the constraint definition, it does not include the validation state. Similar to CHECK constraints (lines 56-64), EXCLUDE constraints should append " NOT VALID" when constraint.IsValid is false to properly represent invalidated exclusion constraints.

Suggested change
// Use the full definition from pg_get_constraintdef()
return fmt.Sprintf("CONSTRAINT %s %s", ir.QuoteIdentifier(constraint.Name), constraint.ExclusionDefinition)
// Use the full definition from pg_get_constraintdef() and append NOT VALID if needed
result := fmt.Sprintf("CONSTRAINT %s %s", ir.QuoteIdentifier(constraint.Name), constraint.ExclusionDefinition)
if !constraint.IsValid {
result += " NOT VALID"
}
return result

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

PostgreSQL does not support NOT VALID for EXCLUDE constraints — only CHECK, FOREIGN KEY, and NOT NULL constraints support it (docs). pg_get_constraintdef() will never include "NOT VALID" for exclusion constraints, so this handling is unnecessary.

default:
return ""
}
Expand All @@ -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]
Expand All @@ -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
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
18 changes: 18 additions & 0 deletions internal/diff/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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{
Expand Down
9 changes: 9 additions & 0 deletions ir/inspector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 != "<nil>" {
c.ExclusionDefinition = exclDef
}
}

// Set validation state from database
c.IsValid = constraint.IsValid

Expand Down
31 changes: 16 additions & 15 deletions ir/ir.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion ir/normalize.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +893 to +895
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

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

The normalizeExclusionDefinition function should strip the " NOT VALID" suffix from the definition, similar to how normalizeCheckClause handles it (lines 904-911). PostgreSQL's pg_get_constraintdef() may include " NOT VALID" at the end for invalidated constraints, but the validation state should be controlled via the IsValid field for consistency with other constraint types.

Suggested change
// 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)
// We keep it as-is since both desired and current state come from pg_get_constraintdef(),
// except for stripping a trailing " NOT VALID" suffix to match normalizeCheckClause behavior.
func normalizeExclusionDefinition(definition string) string {
normalized := strings.TrimSpace(definition)
// Strip " NOT VALID" suffix if present (mimicking pg_dump behavior)
// PostgreSQL's pg_get_constraintdef may include NOT VALID at the end,
// but the validation state should be represented via the IsValid field.
const notValidSuffix = " NOT VALID"
if strings.HasSuffix(normalized, notValidSuffix) {
normalized = strings.TrimSpace(strings.TrimSuffix(normalized, notValidSuffix))
}
return normalized

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Same as above — PostgreSQL does not support NOT VALID for EXCLUDE constraints, so pg_get_constraintdef() will never return a "NOT VALID" suffix for them. Stripping it would be dead code.

}

// normalizeCheckClause normalizes CHECK constraint expressions.
Expand Down
6 changes: 4 additions & 2 deletions ir/queries/queries.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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_%'
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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'
Expand Down
10 changes: 8 additions & 2 deletions ir/queries/queries.sql.go

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

Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE test_table
ADD CONSTRAINT excl_no_overlap EXCLUDE USING gist (range_col WITH &&);
Original file line number Diff line number Diff line change
@@ -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 &&)
);
Original file line number Diff line number Diff line change
@@ -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)
);
20 changes: 20 additions & 0 deletions testdata/diff/create_table/issue_281_exclude_constraint/plan.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE test_table
ADD CONSTRAINT excl_no_overlap EXCLUDE USING gist (range_col WITH &&);
14 changes: 14 additions & 0 deletions testdata/diff/create_table/issue_281_exclude_constraint/plan.txt
Original file line number Diff line number Diff line change
@@ -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 &&);