Skip to content

Commit 0abf597

Browse files
tianzhouclaude
andauthored
feat: add constraints section to .pgschemaignore (#429, #447) (#452)
* feat: add constraints section to .pgschemaignore (#429, #447) Adds a new [constraints] section to .pgschemaignore that matches table constraints by name (glob). Ignored constraints are filtered at the inspector, so they are excluded from dump, plan generation, and drift detection alike — pgschema neither creates, drops, nor reports them. Use cases: - Out-of-band constraints, e.g. disabled during an AWS DMS migration and re-added manually afterward, that plan should not flag for drop (#447). - Cross-schema foreign keys: ignore them to bootstrap each schema independently, then drop the ignore once all tables exist (#429). Mirrors the existing per-object pattern (Indexes, #441): Constraints []string on IgnoreConfig with ShouldIgnoreConstraint(name), a ConstraintIgnoreConfig struct on TomlConfig, and a filter in the inspector's constraint attach loop. Fixes #447 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf: skip ignored constraints before per-column position queries Move the ShouldIgnoreConstraint check into the initial constraint loop, before getConstraintColumnPosition runs per column, so ignored constraints no longer incur N+1 DB queries that are immediately discarded. The attach-time check is removed since ignored constraints now never enter constraintGroups. Addresses Copilot review feedback on #452. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 592c19c commit 0abf597

7 files changed

Lines changed: 192 additions & 10 deletions

File tree

cmd/ignore_integration_test.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1445,6 +1445,125 @@ CREATE INDEX products_name_idx ON products(name);
14451445
})
14461446
}
14471447

1448+
// TestIgnoreConstraints tests that constraints matching .pgschemaignore [constraints]
1449+
// patterns are excluded from dump and plan output.
1450+
// Addresses https://github.com/pgplex/pgschema/issues/447 and https://github.com/pgplex/pgschema/issues/429
1451+
func TestIgnoreConstraints(t *testing.T) {
1452+
if testing.Short() {
1453+
t.Skip("Skipping integration test in short mode")
1454+
}
1455+
1456+
embeddedPG := testutil.SetupPostgres(t)
1457+
defer embeddedPG.Stop()
1458+
conn, host, port, dbname, user, password := testutil.ConnectToPostgres(t, embeddedPG)
1459+
defer conn.Close()
1460+
1461+
containerInfo := &struct {
1462+
Conn *sql.DB
1463+
Host string
1464+
Port int
1465+
DBName string
1466+
User string
1467+
Password string
1468+
}{
1469+
Conn: conn,
1470+
Host: host,
1471+
Port: port,
1472+
DBName: dbname,
1473+
User: user,
1474+
Password: password,
1475+
}
1476+
1477+
// Create tables with a managed primary key plus a manually-added foreign key
1478+
// that is not part of the declared schema (simulates a constraint re-added
1479+
// out-of-band, e.g. after a DMS migration that disabled constraints).
1480+
setupSQL := `
1481+
CREATE TABLE categories (
1482+
id SERIAL PRIMARY KEY,
1483+
name TEXT NOT NULL
1484+
);
1485+
1486+
CREATE TABLE products (
1487+
id SERIAL PRIMARY KEY,
1488+
name TEXT NOT NULL,
1489+
category_id INTEGER
1490+
);
1491+
1492+
ALTER TABLE products
1493+
ADD CONSTRAINT fk_products_category FOREIGN KEY (category_id) REFERENCES categories(id);
1494+
`
1495+
_, err := conn.Exec(setupSQL)
1496+
if err != nil {
1497+
t.Fatalf("Failed to create test schema: %v", err)
1498+
}
1499+
1500+
originalWd, err := os.Getwd()
1501+
if err != nil {
1502+
t.Fatalf("Failed to get current working directory: %v", err)
1503+
}
1504+
defer func() {
1505+
if err := os.Chdir(originalWd); err != nil {
1506+
t.Fatalf("Failed to restore working directory: %v", err)
1507+
}
1508+
}()
1509+
1510+
tmpDir := t.TempDir()
1511+
if err := os.Chdir(tmpDir); err != nil {
1512+
t.Fatalf("Failed to change to temp directory: %v", err)
1513+
}
1514+
1515+
// Ignore any constraint whose name starts with "fk_"
1516+
ignoreContent := `[constraints]
1517+
patterns = ["fk_*"]
1518+
`
1519+
err = os.WriteFile(".pgschemaignore", []byte(ignoreContent), 0644)
1520+
if err != nil {
1521+
t.Fatalf("Failed to create .pgschemaignore: %v", err)
1522+
}
1523+
1524+
t.Run("dump", func(t *testing.T) {
1525+
output := executeIgnoreDumpCommand(t, containerInfo)
1526+
1527+
if !strings.Contains(output, "products_pkey") {
1528+
t.Error("Dump should include products_pkey (not ignored)")
1529+
}
1530+
1531+
if strings.Contains(output, "fk_products_category") {
1532+
t.Error("Dump should not include fk_products_category (ignored by [constraints] patterns)")
1533+
}
1534+
})
1535+
1536+
t.Run("plan", func(t *testing.T) {
1537+
// Desired schema declares the tables but not the foreign key.
1538+
// Without the ignore the plan would emit ALTER TABLE ... DROP CONSTRAINT
1539+
// fk_products_category; with the ignore the plan should not reference it.
1540+
schemaSQL := `
1541+
CREATE TABLE categories (
1542+
id SERIAL PRIMARY KEY,
1543+
name TEXT NOT NULL
1544+
);
1545+
1546+
CREATE TABLE products (
1547+
id SERIAL PRIMARY KEY,
1548+
name TEXT NOT NULL,
1549+
category_id INTEGER
1550+
);
1551+
`
1552+
schemaFile := "schema.sql"
1553+
err := os.WriteFile(schemaFile, []byte(schemaSQL), 0644)
1554+
if err != nil {
1555+
t.Fatalf("Failed to create schema file: %v", err)
1556+
}
1557+
defer os.Remove(schemaFile)
1558+
1559+
output := executeIgnorePlanCommand(t, containerInfo, schemaFile)
1560+
1561+
if strings.Contains(output, "fk_products_category") {
1562+
t.Errorf("Plan should not reference fk_products_category (ignored); got: %s", output)
1563+
}
1564+
})
1565+
}
1566+
14481567
// verifyPlanOutput checks that plan output excludes ignored objects
14491568
func verifyPlanOutput(t *testing.T, output string) {
14501569
// Changes that should appear in plan (regular objects)

cmd/util/ignoreloader.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ type TomlConfig struct {
3535
Types TypeIgnoreConfig `toml:"types,omitempty"`
3636
Sequences SequenceIgnoreConfig `toml:"sequences,omitempty"`
3737
Indexes IndexIgnoreConfig `toml:"indexes,omitempty"`
38+
Constraints ConstraintIgnoreConfig `toml:"constraints,omitempty"`
3839
Privileges PrivilegeIgnoreConfig `toml:"privileges,omitempty"`
3940
DefaultPrivileges DefaultPrivilegeIgnoreConfig `toml:"default_privileges,omitempty"`
4041
}
@@ -74,6 +75,12 @@ type IndexIgnoreConfig struct {
7475
Patterns []string `toml:"patterns,omitempty"`
7576
}
7677

78+
// ConstraintIgnoreConfig represents constraint-specific ignore configuration
79+
// Patterns match on constraint names
80+
type ConstraintIgnoreConfig struct {
81+
Patterns []string `toml:"patterns,omitempty"`
82+
}
83+
7784
// PrivilegeIgnoreConfig represents privilege-specific ignore configuration
7885
// Patterns match on grantee role names
7986
type PrivilegeIgnoreConfig struct {
@@ -118,6 +125,7 @@ func LoadIgnoreFileWithStructureFromPath(filePath string) (*ir.IgnoreConfig, err
118125
Types: tomlConfig.Types.Patterns,
119126
Sequences: tomlConfig.Sequences.Patterns,
120127
Indexes: tomlConfig.Indexes.Patterns,
128+
Constraints: tomlConfig.Constraints.Patterns,
121129
Privileges: tomlConfig.Privileges.Patterns,
122130
DefaultPrivileges: tomlConfig.DefaultPrivileges.Patterns,
123131
}

cmd/util/ignoreloader_test.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ patterns = ["seq_temp_*"]
4444
4545
[indexes]
4646
patterns = ["idx_temp_*"]
47+
48+
[constraints]
49+
patterns = ["fk_temp_*"]
4750
`
4851

4952
err := os.WriteFile(testFile, []byte(tomlContent), 0644)
@@ -86,6 +89,10 @@ patterns = ["idx_temp_*"]
8689
if len(config.Indexes) != 1 || config.Indexes[0] != "idx_temp_*" {
8790
t.Errorf("Expected indexes patterns [\"idx_temp_*\"], got %v", config.Indexes)
8891
}
92+
93+
if len(config.Constraints) != 1 || config.Constraints[0] != "fk_temp_*" {
94+
t.Errorf("Expected constraints patterns [\"fk_temp_*\"], got %v", config.Constraints)
95+
}
8996
}
9097

9198
func TestLoadIgnoreFileWithStructure_ValidTOML(t *testing.T) {
@@ -285,4 +292,4 @@ patterns = ["temp_*"]
285292
if len(config.Functions) != 0 {
286293
t.Errorf("Expected empty functions patterns, got %v", config.Functions)
287294
}
288-
}
295+
}

docs/cli/ignore.mdx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ patterns = ["type_test_*"]
4141
[sequences]
4242
patterns = ["seq_temp_*", "seq_debug_*"]
4343

44+
[indexes]
45+
patterns = ["idx_temp_*", "manual_*"]
46+
47+
[constraints]
48+
patterns = ["fk_legacy_*"]
49+
4450
[privileges]
4551
patterns = ["deploy_bot", "admin_*"]
4652

@@ -104,6 +110,24 @@ patterns = ["deploy_bot"] # Ignore ALTER DEFAULT PRIVILEGES for deploy_bot
104110

105111
The `[privileges]` section filters explicit grants (`GRANT ... TO role`), including column-level privileges. The `[default_privileges]` section filters `ALTER DEFAULT PRIVILEGES` statements.
106112

113+
## Constraints
114+
115+
The `[constraints]` section matches table constraints by **constraint name** (primary keys, unique, foreign keys, check, and exclusion constraints). When a constraint is ignored, pgschema neither creates, drops, nor reports drift on it — it is left entirely to be managed out-of-band.
116+
117+
```toml
118+
[constraints]
119+
patterns = ["fk_*", "!fk_core_*"]
120+
```
121+
122+
This is useful when:
123+
124+
1. **Out-of-band constraints** - A constraint is added and managed manually (e.g. disabled during an AWS DMS migration and re-added afterward), and you don't want `pgschema plan` to flag it for drop.
125+
2. **Cross-schema foreign keys** - When modules live in separate schemas with foreign keys between them, ignore the cross-schema foreign keys so each schema can be bootstrapped independently, then drop the ignore to let pgschema manage them once all tables exist.
126+
127+
<Warning>
128+
Patterns match the constraint name only, which is not necessarily unique across tables. Be careful with broad patterns like `*`, as ignoring a primary key or unique constraint can leave a table without the keys it needs.
129+
</Warning>
130+
107131
## Triggers on Ignored Tables
108132

109133
Triggers can be defined on ignored tables. The table structure is not managed, but the trigger itself is.

ir/ignore.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ type IgnoreConfig struct {
2323
Types []string `toml:"types,omitempty"`
2424
Sequences []string `toml:"sequences,omitempty"`
2525
Indexes []string `toml:"indexes,omitempty"`
26+
Constraints []string `toml:"constraints,omitempty"`
2627
Privileges []string `toml:"privileges,omitempty"`
2728
DefaultPrivileges []string `toml:"default_privileges,omitempty"`
2829
}
@@ -83,6 +84,16 @@ func (c *IgnoreConfig) ShouldIgnoreIndex(indexName string) bool {
8384
return c.shouldIgnore(indexName, c.Indexes)
8485
}
8586

87+
// ShouldIgnoreConstraint checks if a constraint should be ignored based on the patterns.
88+
// Patterns match on the constraint name, letting users preserve constraints that exist
89+
// in the database but are managed out-of-band (e.g. manually re-added after a DMS migration).
90+
func (c *IgnoreConfig) ShouldIgnoreConstraint(constraintName string) bool {
91+
if c == nil {
92+
return false
93+
}
94+
return c.shouldIgnore(constraintName, c.Constraints)
95+
}
96+
8697
// ShouldIgnorePrivilegeByObjectType checks if a privilege should be ignored based on the object name
8798
// and its type. When an object (function, table, etc.) is ignored via its section pattern,
8899
// privileges on that object should also be ignored.
@@ -171,4 +182,4 @@ func matchPattern(pattern, name string) bool {
171182
return pattern == name
172183
}
173184
return matched
174-
}
185+
}

ir/ignore_test.go

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -101,13 +101,14 @@ func TestIgnoreConfig_ShouldIgnoreTable(t *testing.T) {
101101

102102
func TestIgnoreConfig_AllObjectTypes(t *testing.T) {
103103
config := &IgnoreConfig{
104-
Tables: []string{"table_*"},
105-
Views: []string{"view_*"},
106-
Functions: []string{"fn_*"},
107-
Procedures: []string{"sp_*"},
108-
Types: []string{"type_*"},
109-
Sequences: []string{"seq_*"},
110-
Indexes: []string{"idx_*"},
104+
Tables: []string{"table_*"},
105+
Views: []string{"view_*"},
106+
Functions: []string{"fn_*"},
107+
Procedures: []string{"sp_*"},
108+
Types: []string{"type_*"},
109+
Sequences: []string{"seq_*"},
110+
Indexes: []string{"idx_*"},
111+
Constraints: []string{"fk_*"},
111112
}
112113

113114
// Test each object type
@@ -130,6 +131,8 @@ func TestIgnoreConfig_AllObjectTypes(t *testing.T) {
130131
{config.ShouldIgnoreSequence, "user_id_seq", false},
131132
{config.ShouldIgnoreIndex, "idx_temp", true},
132133
{config.ShouldIgnoreIndex, "users_pkey", false},
134+
{config.ShouldIgnoreConstraint, "fk_orders_product", true},
135+
{config.ShouldIgnoreConstraint, "users_pkey", false},
133136
}
134137

135138
for _, tt := range tests {
@@ -165,6 +168,9 @@ func TestIgnoreConfig_NilConfig(t *testing.T) {
165168
if config.ShouldIgnoreIndex("any_index") {
166169
t.Error("nil config should not ignore any index")
167170
}
171+
if config.ShouldIgnoreConstraint("any_constraint") {
172+
t.Error("nil config should not ignore any constraint")
173+
}
168174
}
169175

170176
func TestMatchPattern(t *testing.T) {
@@ -201,4 +207,4 @@ func TestMatchPattern(t *testing.T) {
201207
}
202208
})
203209
}
204-
}
210+
}

ir/inspector.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,13 @@ func (i *Inspector) buildConstraints(ctx context.Context, schema *IR, targetSche
442442
schemaName := constraint.TableSchema
443443
tableName := constraint.TableName
444444
constraintName := constraint.ConstraintName
445+
446+
// Skip ignored constraints early to avoid the per-column position
447+
// queries below for constraints that would be discarded anyway.
448+
if i.ignoreConfig != nil && i.ignoreConfig.ShouldIgnoreConstraint(constraintName) {
449+
continue
450+
}
451+
445452
constraintType := ""
446453
if constraint.ConstraintType.Valid {
447454
constraintType = constraint.ConstraintType.String

0 commit comments

Comments
 (0)