From 5002c0b32e22933c4f9bf397aeab2267db393953 Mon Sep 17 00:00:00 2001 From: Guillaume Date: Sat, 23 May 2026 21:57:24 +0200 Subject: [PATCH] Add support for indexes section in .pgschemaignore Adds a new [indexes] section to .pgschemaignore that lets users preserve indexes which exist in the database but are not declared in their .sql files. Without this, manually-added indexes (e.g. perf hotfixes) are flagged for drop by `pgschema plan`. Mirrors the existing per-object pattern: `Indexes []string` on `IgnoreConfig` with `ShouldIgnoreIndex(name)`, an `IndexIgnoreConfig` struct on `TomlConfig`, and a filter in `Inspector.buildIndexes` that skips ignored index names before they enter the IR. Fixes #406 Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/ignore_integration_test.go | 111 +++++++++++++++++++++++++++++++++ cmd/util/ignoreloader.go | 7 +++ cmd/util/ignoreloader_test.go | 7 +++ ir/ignore.go | 9 +++ ir/ignore_test.go | 6 ++ ir/inspector.go | 5 ++ 6 files changed, 145 insertions(+) diff --git a/cmd/ignore_integration_test.go b/cmd/ignore_integration_test.go index e85b5438..e3fbf119 100644 --- a/cmd/ignore_integration_test.go +++ b/cmd/ignore_integration_test.go @@ -1334,6 +1334,117 @@ GRANT SELECT ON users TO app_user; } } +// TestIgnoreIndexes tests that indexes matching .pgschemaignore [indexes] patterns +// are excluded from dump and plan output. +// Reproduces https://github.com/pgplex/pgschema/issues/406 +func TestIgnoreIndexes(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + embeddedPG := testutil.SetupPostgres(t) + defer embeddedPG.Stop() + conn, host, port, dbname, user, password := testutil.ConnectToPostgres(t, embeddedPG) + defer conn.Close() + + containerInfo := &struct { + Conn *sql.DB + Host string + Port int + DBName string + User string + Password string + }{ + Conn: conn, + Host: host, + Port: port, + DBName: dbname, + User: user, + Password: password, + } + + // Create a table with a managed index plus a manually-added index that + // is not part of the declared schema (simulates a perf hotfix index + // added directly to a production database). + setupSQL := ` +CREATE TABLE products ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + category TEXT +); + +CREATE INDEX products_name_idx ON products(name); +CREATE INDEX manual_perf_idx ON products(category); +` + _, err := conn.Exec(setupSQL) + if err != nil { + t.Fatalf("Failed to create test schema: %v", err) + } + + originalWd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current working directory: %v", err) + } + defer func() { + if err := os.Chdir(originalWd); err != nil { + t.Fatalf("Failed to restore working directory: %v", err) + } + }() + + tmpDir := t.TempDir() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change to temp directory: %v", err) + } + + // Ignore any index whose name starts with "manual_" + ignoreContent := `[indexes] +patterns = ["manual_*"] +` + err = os.WriteFile(".pgschemaignore", []byte(ignoreContent), 0644) + if err != nil { + t.Fatalf("Failed to create .pgschemaignore: %v", err) + } + + t.Run("dump", func(t *testing.T) { + output := executeIgnoreDumpCommand(t, containerInfo) + + if !strings.Contains(output, "products_name_idx") { + t.Error("Dump should include products_name_idx (not ignored)") + } + + if strings.Contains(output, "manual_perf_idx") { + t.Error("Dump should not include manual_perf_idx (ignored by [indexes] patterns)") + } + }) + + t.Run("plan", func(t *testing.T) { + // Desired schema declares products_name_idx but not manual_perf_idx. + // Without the ignore the plan would emit DROP INDEX manual_perf_idx; + // with the ignore the plan should not reference manual_perf_idx at all. + schemaSQL := ` +CREATE TABLE products ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + category TEXT +); + +CREATE INDEX products_name_idx ON products(name); +` + schemaFile := "schema.sql" + err := os.WriteFile(schemaFile, []byte(schemaSQL), 0644) + if err != nil { + t.Fatalf("Failed to create schema file: %v", err) + } + defer os.Remove(schemaFile) + + output := executeIgnorePlanCommand(t, containerInfo, schemaFile) + + if strings.Contains(output, "manual_perf_idx") { + t.Errorf("Plan should not reference manual_perf_idx (ignored); got: %s", output) + } + }) +} + // verifyPlanOutput checks that plan output excludes ignored objects func verifyPlanOutput(t *testing.T, output string) { // Changes that should appear in plan (regular objects) diff --git a/cmd/util/ignoreloader.go b/cmd/util/ignoreloader.go index 59c133b4..1e1ae462 100644 --- a/cmd/util/ignoreloader.go +++ b/cmd/util/ignoreloader.go @@ -34,6 +34,7 @@ type TomlConfig struct { Procedures ProcedureIgnoreConfig `toml:"procedures,omitempty"` Types TypeIgnoreConfig `toml:"types,omitempty"` Sequences SequenceIgnoreConfig `toml:"sequences,omitempty"` + Indexes IndexIgnoreConfig `toml:"indexes,omitempty"` Privileges PrivilegeIgnoreConfig `toml:"privileges,omitempty"` DefaultPrivileges DefaultPrivilegeIgnoreConfig `toml:"default_privileges,omitempty"` } @@ -68,6 +69,11 @@ type SequenceIgnoreConfig struct { Patterns []string `toml:"patterns,omitempty"` } +// IndexIgnoreConfig represents index-specific ignore configuration +type IndexIgnoreConfig struct { + Patterns []string `toml:"patterns,omitempty"` +} + // PrivilegeIgnoreConfig represents privilege-specific ignore configuration // Patterns match on grantee role names type PrivilegeIgnoreConfig struct { @@ -111,6 +117,7 @@ func LoadIgnoreFileWithStructureFromPath(filePath string) (*ir.IgnoreConfig, err Procedures: tomlConfig.Procedures.Patterns, Types: tomlConfig.Types.Patterns, Sequences: tomlConfig.Sequences.Patterns, + Indexes: tomlConfig.Indexes.Patterns, Privileges: tomlConfig.Privileges.Patterns, DefaultPrivileges: tomlConfig.DefaultPrivileges.Patterns, } diff --git a/cmd/util/ignoreloader_test.go b/cmd/util/ignoreloader_test.go index 36fa97f2..464e71c8 100644 --- a/cmd/util/ignoreloader_test.go +++ b/cmd/util/ignoreloader_test.go @@ -41,6 +41,9 @@ patterns = ["type_test_*"] [sequences] patterns = ["seq_temp_*"] + +[indexes] +patterns = ["idx_temp_*"] ` err := os.WriteFile(testFile, []byte(tomlContent), 0644) @@ -79,6 +82,10 @@ patterns = ["seq_temp_*"] if len(config.Procedures) != 1 || config.Procedures[0] != "sp_temp_*" { t.Errorf("Expected procedure patterns [\"sp_temp_*\"], got %v", config.Procedures) } + + if len(config.Indexes) != 1 || config.Indexes[0] != "idx_temp_*" { + t.Errorf("Expected indexes patterns [\"idx_temp_*\"], got %v", config.Indexes) + } } func TestLoadIgnoreFileWithStructure_ValidTOML(t *testing.T) { diff --git a/ir/ignore.go b/ir/ignore.go index a4f6d638..5f55d6e4 100644 --- a/ir/ignore.go +++ b/ir/ignore.go @@ -22,6 +22,7 @@ type IgnoreConfig struct { Procedures []string `toml:"procedures,omitempty"` Types []string `toml:"types,omitempty"` Sequences []string `toml:"sequences,omitempty"` + Indexes []string `toml:"indexes,omitempty"` Privileges []string `toml:"privileges,omitempty"` DefaultPrivileges []string `toml:"default_privileges,omitempty"` } @@ -74,6 +75,14 @@ func (c *IgnoreConfig) ShouldIgnoreSequence(sequenceName string) bool { return c.shouldIgnore(sequenceName, c.Sequences) } +// ShouldIgnoreIndex checks if an index should be ignored based on the patterns +func (c *IgnoreConfig) ShouldIgnoreIndex(indexName string) bool { + if c == nil { + return false + } + return c.shouldIgnore(indexName, c.Indexes) +} + // ShouldIgnorePrivilegeByObjectType checks if a privilege should be ignored based on the object name // and its type. When an object (function, table, etc.) is ignored via its section pattern, // privileges on that object should also be ignored. diff --git a/ir/ignore_test.go b/ir/ignore_test.go index 01cb6447..abd7c6b2 100644 --- a/ir/ignore_test.go +++ b/ir/ignore_test.go @@ -107,6 +107,7 @@ func TestIgnoreConfig_AllObjectTypes(t *testing.T) { Procedures: []string{"sp_*"}, Types: []string{"type_*"}, Sequences: []string{"seq_*"}, + Indexes: []string{"idx_*"}, } // Test each object type @@ -127,6 +128,8 @@ func TestIgnoreConfig_AllObjectTypes(t *testing.T) { {config.ShouldIgnoreType, "user_status", false}, {config.ShouldIgnoreSequence, "seq_temp", true}, {config.ShouldIgnoreSequence, "user_id_seq", false}, + {config.ShouldIgnoreIndex, "idx_temp", true}, + {config.ShouldIgnoreIndex, "users_pkey", false}, } for _, tt := range tests { @@ -159,6 +162,9 @@ func TestIgnoreConfig_NilConfig(t *testing.T) { if config.ShouldIgnoreSequence("any_sequence") { t.Error("nil config should not ignore any sequence") } + if config.ShouldIgnoreIndex("any_index") { + t.Error("nil config should not ignore any index") + } } func TestMatchPattern(t *testing.T) { diff --git a/ir/inspector.go b/ir/inspector.go index 23a0a5c4..e524ed0f 100644 --- a/ir/inspector.go +++ b/ir/inspector.go @@ -741,6 +741,11 @@ func (i *Inspector) buildIndexes(ctx context.Context, schema *IR, targetSchema s tableName := indexRow.Tablename indexName := indexRow.Indexname + // Check if index should be ignored + if i.ignoreConfig != nil && i.ignoreConfig.ShouldIgnoreIndex(indexName) { + continue + } + dbSchema := schema.getOrCreateSchema(schemaName) // Extract values with null safety