From b91985c493fde3955b3abeb7a3b827709de4d692 Mon Sep 17 00:00:00 2001 From: Hein Date: Wed, 23 Sep 2026 18:48:07 +0200 Subject: [PATCH] feat(inspector): fail on duplicate index names per schema PostgreSQL index names share the schema-wide relation namespace, so a reused name makes CREATE INDEX IF NOT EXISTS silently skip the duplicate. Add duplicate_index_name rule (enforce by default) covering indexes and PK/unique constraint backing indexes. --- pkg/inspector/README.md | 1 + pkg/inspector/inspector.go | 1 + pkg/inspector/rules.go | 5 ++ pkg/inspector/rules_test.go | 1 + pkg/inspector/validators.go | 61 ++++++++++++++++++++++++ pkg/inspector/validators_test.go | 81 ++++++++++++++++++++++++++++++++ 6 files changed, 150 insertions(+) diff --git a/pkg/inspector/README.md b/pkg/inspector/README.md index d0126a6..c92e174 100644 --- a/pkg/inspector/README.md +++ b/pkg/inspector/README.md @@ -123,6 +123,7 @@ rules: | `missing_primary_key` | `have_primary_key` | Ensure tables have primary keys | | `orphaned_foreign_key` | `orphaned_foreign_key` | Detect FKs referencing non-existent tables | | `circular_dependency` | `circular_dependency` | Detect circular FK dependencies | +| `duplicate_index_name` | `duplicate_index_name` | Index / PK / unique names must be unique per schema (default: `enforce`) | ## Rule Configuration diff --git a/pkg/inspector/inspector.go b/pkg/inspector/inspector.go index c9545f1..0fbc258 100644 --- a/pkg/inspector/inspector.go +++ b/pkg/inspector/inspector.go @@ -161,6 +161,7 @@ func getValidator(functionName string) (validatorFunc, bool) { "have_primary_key": validateMissingPrimaryKey, "orphaned_foreign_key": validateOrphanedForeignKey, "circular_dependency": validateCircularDependency, + "duplicate_index_name": validateDuplicateIndexName, } fn, exists := validators[functionName] diff --git a/pkg/inspector/rules.go b/pkg/inspector/rules.go index 1bd01b1..8794cd7 100644 --- a/pkg/inspector/rules.go +++ b/pkg/inspector/rules.go @@ -154,6 +154,11 @@ func GetDefaultConfig() *Config { Function: "circular_dependency", Message: "Circular foreign key dependency detected", }, + "duplicate_index_name": { + Enabled: "enforce", + Function: "duplicate_index_name", + Message: "Index name is reused within the schema; PostgreSQL skips the duplicate CREATE INDEX IF NOT EXISTS", + }, }, } } diff --git a/pkg/inspector/rules_test.go b/pkg/inspector/rules_test.go index b7abd57..6f80b19 100644 --- a/pkg/inspector/rules_test.go +++ b/pkg/inspector/rules_test.go @@ -37,6 +37,7 @@ func TestGetDefaultConfig(t *testing.T) { "missing_primary_key", "orphaned_foreign_key", "circular_dependency", + "duplicate_index_name", } for _, ruleName := range expectedRules { diff --git a/pkg/inspector/validators.go b/pkg/inspector/validators.go index 6d36583..0b47d25 100644 --- a/pkg/inspector/validators.go +++ b/pkg/inspector/validators.go @@ -643,3 +643,64 @@ func contains(slice []string, value string) bool { } return false } + +// validateDuplicateIndexName checks that index names are unique per schema. +// PostgreSQL keeps indexes in the schema-wide relation namespace, so a name +// reused on another table makes CREATE INDEX IF NOT EXISTS silently skip it. +// Primary key and unique constraints create backing indexes and share that +// namespace too. An index and a constraint with the same name on the same +// table describe one object and are not reported. +func validateDuplicateIndexName(db *models.Database, rule Rule, ruleName string) []ValidationResult { + results := []ValidationResult{} + + for _, schema := range db.Schemas { + // lowercased name -> "table" entries, one per distinct object + owners := make(map[string][]string) + display := make(map[string]string) + order := []string{} + + add := func(name, table string, sameTableMerges bool) { + key := strings.ToLower(name) + if _, seen := owners[key]; !seen { + order = append(order, key) + display[key] = name + } + if sameTableMerges && contains(owners[key], table) { + return + } + owners[key] = append(owners[key], table) + } + + for _, table := range schema.Tables { + for _, key := range sortedKeys(table.Indexes) { + if name := table.Indexes[key].Name; name != "" { + add(name, table.Name, false) + } + } + for _, c := range sortConstraints(table.Constraints) { + if c.Name == "" || (c.Type != models.PrimaryKeyConstraint && c.Type != models.UniqueConstraint) { + continue + } + add(c.Name, table.Name, true) + } + } + + for _, key := range order { + tables := owners[key] + results = append(results, createResult( + ruleName, + len(tables) == 1, + rule.Message, + formatLocation(schema.Name, display[key], "")+" on "+strings.Join(tables, ", "), + map[string]interface{}{ + "schema": schema.Name, + "index": display[key], + "tables": tables, + "occurrences": len(tables), + }, + )) + } + } + + return results +} diff --git a/pkg/inspector/validators_test.go b/pkg/inspector/validators_test.go index a80737d..b086ffa 100644 --- a/pkg/inspector/validators_test.go +++ b/pkg/inspector/validators_test.go @@ -835,3 +835,84 @@ func TestFormatLocation(t *testing.T) { }) } } + +func TestValidateDuplicateIndexName(t *testing.T) { + db := &models.Database{ + Name: "testdb", + Schemas: []*models.Schema{ + { + Name: "entity", + Tables: []*models.Table{ + { + Name: "actor_phone", + Indexes: map[string]*models.Index{ + "idx_actor": {Name: "idx_actor", Columns: []string{"rid_actor"}}, + "uk_phone": {Name: "uk_phone", Columns: []string{"phone"}, Unique: true}, + }, + Constraints: map[string]*models.Constraint{ + // Same name as the index on the same table: one object. + "uk_phone": {Name: "uk_phone", Type: models.UniqueConstraint, Columns: []string{"phone"}}, + }, + }, + { + Name: "actor_email", + Indexes: map[string]*models.Index{ + "idx_actor": {Name: "idx_actor", Columns: []string{"rid_actor"}}, + }, + Constraints: map[string]*models.Constraint{ + "UK_Phone": {Name: "UK_Phone", Type: models.UniqueConstraint, Columns: []string{"email"}}, + }, + }, + { + Name: "actor_address", + Indexes: map[string]*models.Index{ + "idx_actor": {Name: "idx_actor", Columns: []string{"rid_actor"}}, + "idx_actor#2": {Name: "idx_actor", Columns: []string{"rid_actor", "kind"}}, + "idx_address": {Name: "idx_address", Columns: []string{"line1"}}, + }, + }, + }, + }, + { + // Same names in another schema do not collide. + Name: "org", + Tables: []*models.Table{ + {Name: "api_provider", Indexes: map[string]*models.Index{"idx_actor": {Name: "idx_actor"}}}, + }, + }, + }, + } + + results := validateDuplicateIndexName(db, Rule{Message: "dup"}, "duplicate_index_name") + + got := map[string]bool{} + occ := map[string]int{} + for _, r := range results { + key := r.Context["schema"].(string) + "." + r.Context["index"].(string) + got[key] = r.Passed + occ[key] = r.Context["occurrences"].(int) + } + + want := map[string]struct { + passed bool + occ int + }{ + "entity.idx_actor": {false, 4}, + "entity.uk_phone": {false, 2}, + "entity.idx_address": {true, 1}, + "org.idx_actor": {true, 1}, + } + if len(got) != len(want) { + t.Fatalf("got %d results %v, want %d", len(got), got, len(want)) + } + for k, w := range want { + p, ok := got[k] + if !ok { + t.Errorf("missing result for %s", k) + continue + } + if p != w.passed || occ[k] != w.occ { + t.Errorf("%s: passed=%v occurrences=%d, want passed=%v occurrences=%d", k, p, occ[k], w.passed, w.occ) + } + } +}