diff --git a/docs/PGFATHOM.md b/docs/PGFATHOM.md index 76175d3..6bcd3f3 100644 --- a/docs/PGFATHOM.md +++ b/docs/PGFATHOM.md @@ -185,7 +185,7 @@ type Table struct { Name string Columns []Column PrimaryKey []string // nomes de coluna, em ordem - Uniques [][]string + Uniques []UniqueConstraint ForeignKeys []ForeignKey // apenas as DECLARADAS Indexes []Index Stats TableStats diff --git a/internal/audit/audit.go b/internal/audit/audit.go index 1e8678a..a1e32d8 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -1,28 +1,49 @@ // Package audit derives findings that require no inference at all. // -// Everything here comes straight from the catalog: deterministic, free, and -// immune to false positives. Every finding it emits is a fact, not a -// hypothesis, which is also what lets the command run usefully against a -// database where inference would have nothing to say. +// Everything here comes straight from the catalog and from usage evidence +// already resolved against it: deterministic, free of guesswork, and immune +// to false positives. Every finding it emits is a fact — a missing key, a +// column real code repeatedly names with no index leading it — not a +// hypothesis about the data. This package never opens a transaction and never +// reads a row; the one probe that does, confirming a candidate key by +// counting, lives in internal/validate and is costured in by internal/cli. package audit import ( "sort" + "strings" "github.com/lvcas-dotcom/pgfathom/internal/model" ) +// Options carries the usage evidence and environment the evidence-based +// findings need. Everything here is catalog output or already-resolved +// extractor output — no table data reaches this package. +type Options struct { + Joins []model.JoinEvidence + Predicates []model.PredicateEvidence + Extensions model.ExtensionSet + + // RecurrenceMin is how many distinct objects (views, functions, + // statements) must name a column before it counts as hot. Values below 1 + // are treated as 1. + RecurrenceMin int +} + // Findings collects the structural findings for a set of schemas. -func Findings(schemas []model.Schema) []model.Finding { +func Findings(schemas []model.Schema, opts Options) []model.Finding { var out []model.Finding for _, s := range schemas { for _, t := range s.Tables { out = append(out, notValidConstraints(t)...) out = append(out, unindexedForeignKeys(t, schemas)...) + out = append(out, missingPrimaryKeys(t)...) } } + out = append(out, unindexedHotColumns(schemas, opts)...) + sort.SliceStable(out, func(i, j int) bool { if out[i].Kind != out[j].Kind { return out[i].Kind < out[j].Kind @@ -90,3 +111,162 @@ func findTable(schemas []model.Schema, schema, name string) (model.Table, bool) } return model.Table{}, false } + +// missingPrimaryKeys reports a table with no primary key. When the catalog +// already proves a UNIQUE NOT NULL constraint exists, promoting it is offered +// as a fix that needs no data probe — the catalog already did that work. +// Otherwise the suggestion stands without columns: naming a candidate key +// requires reading data, which is internal/cli's job to costure in via +// internal/validate, not this package's. +func missingPrimaryKeys(t model.Table) []model.Finding { + if t.HasPrimaryKey() { + return nil + } + + f := model.Finding{ + Kind: model.FindingMissingPrimaryKey, + Object: t.Ref(), + Metrics: map[string]int64{"estimated_rows": t.Stats.EstimatedRows}, + } + + if u, ok := t.PromotableUnique(); ok { + f.Detail = "no primary key: row identity is undefined, but an existing " + + "UNIQUE NOT NULL constraint already proves one — promoting it needs " + + "no data probe, only a lock-light DDL sequence" + f.Suggestion = &model.Suggestion{ + Kind: model.SuggestPromoteUnique, + Columns: u.Columns, + } + } else { + f.Detail = "no primary key: row identity is undefined, no logical replication " + + "covers it, and every per-row update or delete scans sequentially" + f.Suggestion = &model.Suggestion{Kind: model.SuggestCreatePrimaryKey} + } + + return []model.Finding{f} +} + +// operatorPriority orders operator classes by how much they need a method +// beyond btree, most demanding first. When a column carries more than one +// kind of predicate, the recommendation follows whichever one btree cannot +// serve well — recommending GIN for a column that also happens to see plain +// equality elsewhere costs nothing, while the reverse would miss the point. +var operatorPriority = []model.OperatorClass{ + model.OpVectorDistance, + model.OpContainment, + model.OpFullText, + model.OpLikeInfix, + model.OpRange, + model.OpLikePrefix, + model.OpEquality, +} + +func dominantOperator(seen map[model.OperatorClass]bool) model.OperatorClass { + for _, op := range operatorPriority { + if seen[op] { + return op + } + } + return model.OpEquality +} + +// columnUsage tallies, per column, how many distinct objects name it in a +// join or filter predicate, and which operators were seen. +type columnUsage struct { + objects map[string]bool + ops map[model.OperatorClass]bool +} + +func tallyColumnUsage(opts Options) map[model.ColumnRef]*columnUsage { + usage := make(map[model.ColumnRef]*columnUsage) + + touch := func(ref model.ColumnRef, op model.OperatorClass, object string) { + u, ok := usage[ref] + if !ok { + u = &columnUsage{objects: map[string]bool{}, ops: map[model.OperatorClass]bool{}} + usage[ref] = u + } + u.objects[object] = true + u.ops[op] = true + } + + // A join implies both sides are looked up by equality, which is a + // hot-column signal in its own right even though it never yields anything + // beyond btree. + for _, j := range opts.Joins { + touch(j.Left, model.OpEquality, j.Object) + touch(j.Right, model.OpEquality, j.Object) + } + for _, p := range opts.Predicates { + touch(p.Column, p.Operator, p.Object) + } + + return usage +} + +// unindexedHotColumns reports a column that real code — a view, a function, +// or the query log — repeatedly names in a join or filter predicate, with no +// index leading it. A column already covered by fk_without_index is not +// duplicated here: it is the same problem, reported once. +func unindexedHotColumns(schemas []model.Schema, opts Options) []model.Finding { + recurrenceMin := opts.RecurrenceMin + if recurrenceMin < 1 { + recurrenceMin = 1 + } + + usage := tallyColumnUsage(opts) + + var out []model.Finding + for ref, u := range usage { + if len(u.objects) < recurrenceMin { + continue + } + + t, ok := findTable(schemas, ref.Schema, ref.Table) + if !ok || t.IsIndexedLeading(ref.Column) || leadsUnindexedForeignKey(t, ref.Column) { + continue + } + + col, ok := t.Column(ref.Column) + if !ok { + continue + } + + method, opclass, note := model.IndexMethodFor(dominantOperator(u.ops), col.BaseType, opts.Extensions) + if method == "" { + // No honest recommendation exists — e.g. a vector distance operator + // without pgvector installed, or a containment operator on a type GIN + // has no default operator class for. Silence beats a suggestion the + // server would reject. + continue + } + + out = append(out, model.Finding{ + Kind: model.FindingUnindexedHotColumn, + Object: ref.String(), + Detail: "column leads no index but real code names it in a predicate", + Metrics: map[string]int64{"distinct_objects": int64(len(u.objects))}, + Suggestion: &model.Suggestion{ + Kind: model.SuggestCreateIndex, + Columns: []string{col.Name}, + IndexMethod: method, + IndexOpclass: opclass, + Note: note, + }, + }) + } + + return out +} + +// leadsUnindexedForeignKey reports whether the column already surfaces as +// fk_without_index, which would otherwise make the same problem show up +// twice under two different finding kinds. +func leadsUnindexedForeignKey(t model.Table, column string) bool { + for _, fk := range t.ForeignKeys { + if len(fk.Columns) > 0 && strings.EqualFold(fk.Columns[0], column) && !fk.HasIndex { + return true + } + } + return false +} diff --git a/internal/audit/audit_integration_test.go b/internal/audit/audit_integration_test.go index 22f3b98..2baa978 100644 --- a/internal/audit/audit_integration_test.go +++ b/internal/audit/audit_integration_test.go @@ -38,7 +38,7 @@ func run(t *testing.T, fixture string) *model.Result { result := model.NewResult("integration", "", time.Unix(0, 0).UTC(), cat.Coverage) result.ServerVersion = pool.ServerVersion() result.Schemas = cat.Schemas - result.Findings = audit.Findings(cat.Schemas) + result.Findings = audit.Findings(cat.Schemas, audit.Options{}) return result } diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go index cb7935c..21cca3e 100644 --- a/internal/audit/audit_test.go +++ b/internal/audit/audit_test.go @@ -1,6 +1,11 @@ package audit_test import ( + "go/parser" + "go/token" + "os" + "path/filepath" + "strconv" "strings" "testing" @@ -8,6 +13,63 @@ import ( "github.com/lvcas-dotcom/pgfathom/internal/model" ) +// forbiddenForAudit are packages that could read table data or open a +// transaction. internal/validate is where the one probe that reads data +// lives, on purpose, in a different package: audit stays provably free of +// that capability. +var forbiddenForAudit = []string{ + "github.com/lvcas-dotcom/pgfathom/internal/validate", + "github.com/lvcas-dotcom/pgfathom/internal/db", + "github.com/jackc/pgx/v5", +} + +// TestPackageNeverReadsData enforces that internal/audit cannot open a +// transaction or read a row. It is the same shape as internal/model's purity +// test, and exists for the same reason: the property is easy to break with +// one convenient import and expensive to notice afterward without a test. +func TestPackageNeverReadsData(t *testing.T) { + sources, err := filepath.Glob("*.go") + if err != nil { + t.Fatalf("listing sources: %v", err) + } + + fset := token.NewFileSet() + checked := 0 + + for _, path := range sources { + if strings.HasSuffix(path, "_test.go") { + continue + } + checked++ + + src, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading %s: %v", path, err) + } + + file, err := parser.ParseFile(fset, path, src, parser.ImportsOnly) + if err != nil { + t.Fatalf("parsing %s: %v", path, err) + } + + for _, imp := range file.Imports { + p, err := strconv.Unquote(imp.Path.Value) + if err != nil { + t.Fatalf("%s: unquoting import %s: %v", path, imp.Path.Value, err) + } + for _, forbidden := range forbiddenForAudit { + if p == forbidden { + t.Errorf("%s imports %q: internal/audit must never read table data", path, p) + } + } + } + } + + if checked == 0 { + t.Fatal("no non-test source files found; the purity check would pass vacuously") + } +} + func schemaWith(tables ...model.Table) []model.Schema { return []model.Schema{{Name: "public", Tables: tables}} } @@ -55,7 +117,7 @@ func countKind(findings []model.Finding, k model.FindingKind) int { func TestNotValidConstraintIsReported(t *testing.T) { schemas := schemaWith(table("pedido", fk("pedido_cliente_fk", "cliente_id", "cliente", false, true))) - findings := audit.Findings(schemas) + findings := audit.Findings(schemas, audit.Options{}) if got := countKind(findings, model.FindingNotValidConstraint); got != 1 { t.Fatalf("got %d NOT VALID findings, want 1 (kinds: %v)", got, kinds(findings)) @@ -73,7 +135,7 @@ func TestNotValidConstraintIsReported(t *testing.T) { func TestValidatedConstraintIsQuiet(t *testing.T) { schemas := schemaWith(table("pedido", fk("pedido_cliente_fk", "cliente_id", "cliente", true, true))) - if got := countKind(audit.Findings(schemas), model.FindingNotValidConstraint); got != 0 { + if got := countKind(audit.Findings(schemas, audit.Options{}), model.FindingNotValidConstraint); got != 0 { t.Errorf("got %d NOT VALID findings on a fully validated schema, want 0", got) } } @@ -87,7 +149,7 @@ func TestUnindexedForeignKeyIsReported(t *testing.T) { parent, ) - findings := audit.Findings(schemas) + findings := audit.Findings(schemas, audit.Options{}) if got := countKind(findings, model.FindingFKWithoutIndex); got != 1 { t.Fatalf("got %d unindexed findings, want 1 (kinds: %v)", got, kinds(findings)) @@ -108,7 +170,7 @@ func TestUnindexedForeignKeyIsReported(t *testing.T) { func TestIndexedForeignKeyIsQuiet(t *testing.T) { schemas := schemaWith(table("pedido", fk("pedido_cliente_fk", "cliente_id", "cliente", true, true))) - if got := countKind(audit.Findings(schemas), model.FindingFKWithoutIndex); got != 0 { + if got := countKind(audit.Findings(schemas, audit.Options{}), model.FindingFKWithoutIndex); got != 0 { t.Errorf("got %d unindexed findings on an indexed schema, want 0", got) } } @@ -116,7 +178,7 @@ func TestIndexedForeignKeyIsQuiet(t *testing.T) { func TestOneConstraintCanProduceBothFindings(t *testing.T) { schemas := schemaWith(table("pedido", fk("pedido_cliente_fk", "cliente_id", "cliente", false, false))) - findings := audit.Findings(schemas) + findings := audit.Findings(schemas, audit.Options{}) if len(findings) != 2 { t.Fatalf("got %d findings, want 2 (kinds: %v)", len(findings), kinds(findings)) @@ -136,9 +198,9 @@ func TestFindingsAreOrderStable(t *testing.T) { table("alpha", fk("alpha_fk", "b_id", "beta", false, false)), ) - first := audit.Findings(schemas) + first := audit.Findings(schemas, audit.Options{}) for i := 0; i < 20; i++ { - again := audit.Findings(schemas) + again := audit.Findings(schemas, audit.Options{}) for j := range first { if first[j].Object != again[j].Object || first[j].Kind != again[j].Kind { t.Fatalf("ordering changed between runs at %d: %v vs %v", j, first[j], again[j]) @@ -148,10 +210,233 @@ func TestFindingsAreOrderStable(t *testing.T) { } func TestEmptySchemaProducesNothing(t *testing.T) { - if findings := audit.Findings(nil); len(findings) != 0 { + if findings := audit.Findings(nil, audit.Options{}); len(findings) != 0 { t.Errorf("got %d findings from no schemas, want 0", len(findings)) } - if findings := audit.Findings(schemaWith()); len(findings) != 0 { + if findings := audit.Findings(schemaWith(), audit.Options{}); len(findings) != 0 { t.Errorf("got %d findings from an empty schema, want 0", len(findings)) } } + +// tableNoPK builds a table without a primary key, with columns and optional +// uniques and indexes — the shape the missing-key and hot-column generators +// need to inspect. +func tableNoPK(name string, columns []model.Column, uniques []model.UniqueConstraint, indexes []model.Index) model.Table { + return model.Table{ + Schema: "public", + Name: name, + Columns: columns, + Uniques: uniques, + Indexes: indexes, + Stats: model.TableStats{EstimatedRows: 1_500_000}, + } +} + +func col(name string, nullable bool) model.Column { + return model.Column{Name: name, Type: "bigint", BaseType: "int8", Nullable: nullable} +} + +func TestMissingPrimaryKeyWithPromotableUniqueOffersPromotion(t *testing.T) { + t.Parallel() + tbl := tableNoPK("cadastro", + []model.Column{col("id", false), col("cpf", false)}, + []model.UniqueConstraint{{Name: "cadastro_cpf_key", Columns: []string{"cpf"}}}, nil) + + findings := audit.Findings(schemaWith(tbl), audit.Options{}) + + if got := countKind(findings, model.FindingMissingPrimaryKey); got != 1 { + t.Fatalf("got %d missing-PK findings, want 1 (kinds: %v)", got, kinds(findings)) + } + + s := findings[0].Suggestion + if s == nil || s.Kind != model.SuggestPromoteUnique || len(s.Columns) != 1 || s.Columns[0] != "cpf" { + t.Errorf("suggestion = %+v, want promote_unique over [cpf]", s) + } +} + +func TestMissingPrimaryKeyWithNullableUniqueIsNotPromotable(t *testing.T) { + t.Parallel() + tbl := tableNoPK("cadastro", + []model.Column{col("id", false), col("cpf", true)}, + []model.UniqueConstraint{{Name: "cadastro_cpf_key", Columns: []string{"cpf"}}}, nil) + + findings := audit.Findings(schemaWith(tbl), audit.Options{}) + + s := findings[0].Suggestion + if s == nil || s.Kind != model.SuggestCreatePrimaryKey || len(s.Columns) != 0 { + t.Errorf("suggestion = %+v, want create_primary_key with no columns: a nullable unique cannot be promoted", s) + } +} + +func TestMissingPrimaryKeyWithoutAnyUniqueNeedsProbing(t *testing.T) { + t.Parallel() + tbl := tableNoPK("cadastro", []model.Column{col("id", false)}, nil, nil) + + findings := audit.Findings(schemaWith(tbl), audit.Options{}) + + if got := countKind(findings, model.FindingMissingPrimaryKey); got != 1 { + t.Fatalf("got %d missing-PK findings, want 1", got) + } + s := findings[0].Suggestion + if s == nil || s.Kind != model.SuggestCreatePrimaryKey || s.KeyProbe != "" { + t.Errorf("suggestion = %+v, want create_primary_key with no probe verdict yet: "+ + "naming a candidate key needs data, which this package never reads", s) + } +} + +func TestTableWithPrimaryKeyProducesNoMissingKeyFinding(t *testing.T) { + t.Parallel() + schemas := schemaWith(table("pedido")) + + if got := countKind(audit.Findings(schemas, audit.Options{}), model.FindingMissingPrimaryKey); got != 0 { + t.Errorf("got %d missing-PK findings on a table that has one, want 0", got) + } +} + +func ref(table, column string) model.ColumnRef { + return model.ColumnRef{Schema: "public", Table: table, Column: column} +} + +func TestHotColumnFromRepeatedJoinsIsReported(t *testing.T) { + t.Parallel() + tbl := table("pedido") + tbl.Columns = []model.Column{col("id", false), col("cliente_id", false)} + schemas := schemaWith(tbl, table("cliente")) + + joins := []model.JoinEvidence{ + {Left: ref("pedido", "cliente_id"), Right: ref("cliente", "id"), Source: model.JoinFromView, Object: "vw_a"}, + {Left: ref("pedido", "cliente_id"), Right: ref("cliente", "id"), Source: model.JoinFromFunction, Object: "fn_b"}, + } + + findings := audit.Findings(schemas, audit.Options{Joins: joins, RecurrenceMin: 2}) + + if got := countKind(findings, model.FindingUnindexedHotColumn); got != 1 { + t.Fatalf("got %d hot-column findings, want 1 (kinds: %v)", got, kinds(findings)) + } +} + +func TestHotColumnBelowRecurrenceThresholdIsQuiet(t *testing.T) { + t.Parallel() + tbl := table("pedido") + tbl.Columns = []model.Column{col("id", false), col("cliente_id", false)} + schemas := schemaWith(tbl, table("cliente")) + + joins := []model.JoinEvidence{ + {Left: ref("pedido", "cliente_id"), Right: ref("cliente", "id"), Source: model.JoinFromView, Object: "vw_a"}, + } + + findings := audit.Findings(schemas, audit.Options{Joins: joins, RecurrenceMin: 2}) + + if got := countKind(findings, model.FindingUnindexedHotColumn); got != 0 { + t.Errorf("got %d hot-column findings below the recurrence threshold, want 0", got) + } +} + +func TestHotColumnAlreadyIndexedIsQuiet(t *testing.T) { + t.Parallel() + tbl := table("pedido") + tbl.Columns = []model.Column{col("id", false), col("cliente_id", false)} + tbl.Indexes = []model.Index{{Name: "ix_pedido_cliente", Columns: []string{"cliente_id"}}} + schemas := schemaWith(tbl, table("cliente")) + + joins := []model.JoinEvidence{ + {Left: ref("pedido", "cliente_id"), Right: ref("cliente", "id"), Source: model.JoinFromView, Object: "vw_a"}, + {Left: ref("pedido", "cliente_id"), Right: ref("cliente", "id"), Source: model.JoinFromFunction, Object: "fn_b"}, + } + + findings := audit.Findings(schemas, audit.Options{Joins: joins, RecurrenceMin: 2}) + + if got := countKind(findings, model.FindingUnindexedHotColumn); got != 0 { + t.Errorf("got %d hot-column findings on an already-indexed column, want 0", got) + } +} + +// TestHotColumnCoveredByFKFindingIsNotDuplicated pins the rule that an +// unindexed FK child column is reported once, as fk_without_index, never +// twice under a second finding kind for the same problem. +func TestHotColumnCoveredByFKFindingIsNotDuplicated(t *testing.T) { + t.Parallel() + tbl := table("pedido", fk("pedido_cliente_fk", "cliente_id", "cliente", true, false)) + tbl.Columns = []model.Column{col("id", false), col("cliente_id", false)} + schemas := schemaWith(tbl, table("cliente")) + + joins := []model.JoinEvidence{ + {Left: ref("pedido", "cliente_id"), Right: ref("cliente", "id"), Source: model.JoinFromView, Object: "vw_a"}, + {Left: ref("pedido", "cliente_id"), Right: ref("cliente", "id"), Source: model.JoinFromFunction, Object: "fn_b"}, + } + + findings := audit.Findings(schemas, audit.Options{Joins: joins, RecurrenceMin: 2}) + + if got := countKind(findings, model.FindingFKWithoutIndex); got != 1 { + t.Errorf("got %d fk_without_index findings, want 1", got) + } + if got := countKind(findings, model.FindingUnindexedHotColumn); got != 0 { + t.Errorf("got %d unindexed_hot_column findings, want 0: already covered by fk_without_index", got) + } +} + +func TestHotColumnIndexMethodFollowsContainmentPredicate(t *testing.T) { + t.Parallel() + tbl := table("evento") + tbl.Columns = []model.Column{col("id", false), {Name: "dados", Type: "jsonb", BaseType: "jsonb"}} + schemas := schemaWith(tbl) + + preds := []model.PredicateEvidence{ + {Column: ref("evento", "dados"), Operator: model.OpContainment, Source: model.JoinFromFunction, Object: "fn_a"}, + {Column: ref("evento", "dados"), Operator: model.OpContainment, Source: model.JoinFromView, Object: "vw_b"}, + } + + findings := audit.Findings(schemas, audit.Options{Predicates: preds, RecurrenceMin: 2}) + + if got := countKind(findings, model.FindingUnindexedHotColumn); got != 1 { + t.Fatalf("got %d hot-column findings, want 1 (kinds: %v)", got, kinds(findings)) + } + s := findings[0].Suggestion + if s == nil || s.IndexMethod != "gin" { + t.Errorf("suggestion = %+v, want GIN for a containment predicate", s) + } +} + +// TestHotColumnVectorDistanceWithoutPgvectorIsOmitted pins the rule that a +// method-gated recommendation degrades to silence rather than to a +// recommendation the database cannot satisfy. +func TestHotColumnVectorDistanceWithoutPgvectorIsOmitted(t *testing.T) { + t.Parallel() + tbl := table("documento") + tbl.Columns = []model.Column{col("id", false), {Name: "embedding", Type: "vector", BaseType: "vector"}} + schemas := schemaWith(tbl) + + preds := []model.PredicateEvidence{ + {Column: ref("documento", "embedding"), Operator: model.OpVectorDistance, Source: model.JoinFromFunction, Object: "fn_a"}, + {Column: ref("documento", "embedding"), Operator: model.OpVectorDistance, Source: model.JoinFromView, Object: "vw_b"}, + } + + findings := audit.Findings(schemas, audit.Options{Predicates: preds, RecurrenceMin: 2, Extensions: model.NewExtensionSet(nil)}) + + if got := countKind(findings, model.FindingUnindexedHotColumn); got != 0 { + t.Errorf("got %d hot-column findings, want 0: no honest recommendation without pgvector", got) + } +} + +func TestHotColumnVectorDistanceWithPgvectorRecommendsHNSW(t *testing.T) { + t.Parallel() + tbl := table("documento") + tbl.Columns = []model.Column{col("id", false), {Name: "embedding", Type: "vector", BaseType: "vector"}} + schemas := schemaWith(tbl) + + preds := []model.PredicateEvidence{ + {Column: ref("documento", "embedding"), Operator: model.OpVectorDistance, Source: model.JoinFromFunction, Object: "fn_a"}, + {Column: ref("documento", "embedding"), Operator: model.OpVectorDistance, Source: model.JoinFromView, Object: "vw_b"}, + } + + findings := audit.Findings(schemas, audit.Options{ + Predicates: preds, RecurrenceMin: 2, Extensions: model.NewExtensionSet([]string{"vector"}), + }) + + if got := countKind(findings, model.FindingUnindexedHotColumn); got != 1 { + t.Fatalf("got %d hot-column findings, want 1", got) + } + if s := findings[0].Suggestion; s == nil || s.IndexMethod != "hnsw" { + t.Errorf("suggestion = %+v, want hnsw with pgvector installed", findings[0].Suggestion) + } +} diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index a8479f8..64d3984 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -189,8 +189,9 @@ func (s *Scope) stamp(c *model.Coverage) { // Result is what a catalog read produced, together with what it could not. type Result struct { - Schemas []model.Schema - Coverage model.Coverage + Schemas []model.Schema + Coverage model.Coverage + Extensions model.ExtensionSet } // Read loads the catalog for the schemas in scope. @@ -222,7 +223,34 @@ func Read(ctx context.Context, pool Querier, opts Options) (*Result, error) { linkForeignKeyIndexes(tables) classifyUnsupported(tables, &coverage) - return &Result{Schemas: group(schemas, tables), Coverage: coverage}, nil + extensions := readExtensions(ctx, pool) + + return &Result{Schemas: group(schemas, tables), Coverage: coverage, Extensions: extensions}, nil +} + +// readExtensions lists installed extensions. A failed read degrades to an +// empty set rather than an error: an index method recommendation gated on an +// extension is a nice-to-have, not something worth failing the whole audit +// over. +func readExtensions(ctx context.Context, pool Querier) model.ExtensionSet { + rows, err := pool.Query(ctx, queryExtensions) + if err != nil { + return model.NewExtensionSet(nil) + } + defer rows.Close() + + var names []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return model.NewExtensionSet(nil) + } + names = append(names, name) + } + if rows.Err() != nil { + return model.NewExtensionSet(nil) + } + return model.NewExtensionSet(names) } // tableKey identifies a table across the several passes. @@ -328,7 +356,7 @@ func readConstraints(ctx context.Context, pool Querier, schemas []string, tables case "p": t.PrimaryKey = columns case "u": - t.Uniques = append(t.Uniques, columns) + t.Uniques = append(t.Uniques, model.UniqueConstraint{Name: name, Columns: columns}) case "f": t.ForeignKeys = append(t.ForeignKeys, model.ForeignKey{ Name: name, diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go index 5d40be3..e4d347a 100644 --- a/internal/catalog/catalog_test.go +++ b/internal/catalog/catalog_test.go @@ -354,7 +354,7 @@ func TestQueriesTouchOnlyTheCatalog(t *testing.T) { allowed := map[string]bool{ "pg_class": true, "pg_namespace": true, "pg_attribute": true, "pg_type": true, "pg_attrdef": true, "pg_constraint": true, "pg_index": true, "pg_inherits": true, - "pg_stat_user_tables": true, "pg_stat_database": true, + "pg_stat_user_tables": true, "pg_stat_database": true, "pg_extension": true, "unnest": true, "lateral": true, } diff --git a/internal/catalog/queries.go b/internal/catalog/queries.go index 2308817..e016ba4 100644 --- a/internal/catalog/queries.go +++ b/internal/catalog/queries.go @@ -169,3 +169,9 @@ const queryStatsReset = ` SELECT stats_reset FROM pg_stat_database WHERE datname = current_database()` + +// queryExtensions lists installed extensions, so an index method recommendation +// can be gated on pg_trgm or pgvector actually being present instead of guessed. +const queryExtensions = ` +SELECT extname + FROM pg_extension` diff --git a/internal/cli/audit.go b/internal/cli/audit.go index 1c5f148..782a5df 100644 --- a/internal/cli/audit.go +++ b/internal/cli/audit.go @@ -1,8 +1,12 @@ package cli import ( + "bufio" "context" + "errors" "fmt" + "io" + "strings" "time" "github.com/spf13/cobra" @@ -10,27 +14,78 @@ import ( "github.com/lvcas-dotcom/pgfathom/internal/audit" "github.com/lvcas-dotcom/pgfathom/internal/buildinfo" "github.com/lvcas-dotcom/pgfathom/internal/catalog" + "github.com/lvcas-dotcom/pgfathom/internal/db" "github.com/lvcas-dotcom/pgfathom/internal/model" + "github.com/lvcas-dotcom/pgfathom/internal/profile" "github.com/lvcas-dotcom/pgfathom/internal/report" + "github.com/lvcas-dotcom/pgfathom/internal/sqlprobe" + "github.com/lvcas-dotcom/pgfathom/internal/validate" +) + +// Defaults for the key-probing flags, declared as estimates like validate's +// own defaults and revisited with the benchmark corpus. +const ( + // DefaultProbeKeysMaxRows is the estimated-row ceiling above which a + // table's missing key is reported without a data probe: a full scan of a + // table this size is a cost the command should never impose by default. + DefaultProbeKeysMaxRows = 5_000_000 + + // DefaultRecurrenceMin is how many distinct views, functions or + // statements must name a column before it counts as hot. + DefaultRecurrenceMin = 2 + + // maxKeyProbeCandidatesPerTable caps how many full-scan uniqueness probes + // one table can receive in a single run. Probing is rare enough, and a + // full scan costly enough, that trying every non-unique index plus every + // column would turn one missing key into a burst of table scans. + maxKeyProbeCandidatesPerTable = 3 ) type auditOptions struct { - connection connectionOptions - format string - out string + connection connectionOptions + profile string + format string + out string + noProbeKeys bool + probeKeysMaxRows int64 + recurrenceMin int } func newAuditCommand(streams *Streams) *cobra.Command { - opts := &auditOptions{connection: defaultConnectionOptions(), format: "table"} + opts := &auditOptions{ + connection: defaultConnectionOptions(), + profile: profile.DefaultName, + format: "table", + probeKeysMaxRows: DefaultProbeKeysMaxRows, + recurrenceMin: DefaultRecurrenceMin, + } cmd := &cobra.Command{ Use: "audit", Short: "Report structural findings that need no inference", - Long: `Report structural findings taken straight from the catalog. + Long: `Report structural findings taken straight from the catalog and from usage +evidence already resolved against it. -audit makes no inferences: every finding it emits is a fact about the schema, -not a hypothesis about the data. It reports foreign keys declared NOT VALID and -never verified, and foreign keys with no index on the child side. +audit makes no inferences: every finding it emits is a fact, not a hypothesis. +It reports foreign keys declared NOT VALID and never verified, foreign keys +with no index on the child side, tables with no primary key, and columns real +code repeatedly names in a join or filter predicate with no index leading them. + +For a table with no primary key and no promotable unique, audit confirms a +candidate key by counting rows — never sampled, and never affirmed unless a +full scan proves it. This is the one place the command reads table data; pass +--no-probe-keys to keep it catalog-only. + +When run at an interactive terminal, every table that reaches the end of that +path with no key confirmed is resolved once, together, before the run +continues: audit reports how many tables are still unresolved, how many have +an untested composite candidate built from their own foreign keys, and what +the schema's declared keys already say a primary key is usually called, then +asks once whether to recommend the composite candidates, a synthetic column +named after that same convention, or neither. A synthetic column is never +named by typing one in; it always follows the convention the rest of the +schema already uses. Piped output, redirected input, and non-interactive runs +never see a prompt. Relationship inference is a separate command; one does not substitute for the other.`, @@ -43,9 +98,17 @@ other.`, registerConnectionFlags(cmd, &opts.connection) f := cmd.Flags() + f.StringVar(&opts.profile, "profile", opts.profile, + "naming profile: a built-in name or a path to a TOML file; only used to name a synthetic primary key") f.StringVar(&opts.format, "format", opts.format, "output format: table, json or sql") f.StringVar(&opts.out, "out", "", "directory for the reviewable .sql artifacts; required by --format sql") + f.BoolVar(&opts.noProbeKeys, "no-probe-keys", false, + "never read table data to confirm a candidate primary key; catalog-only suggestions, and no interactive key resolution") + f.Int64Var(&opts.probeKeysMaxRows, "probe-keys-max-rows", opts.probeKeysMaxRows, + "tables above this estimated row count are not probed for a missing key") + f.IntVar(&opts.recurrenceMin, "recurrence-min", opts.recurrenceMin, + "distinct views/functions/statements a column must appear in before it counts as hot") return cmd } @@ -55,6 +118,11 @@ func runAudit(ctx context.Context, streams *Streams, opts *auditOptions) error { return err } + naming, err := profile.Load(opts.profile) + if err != nil { + return UsageError(err) + } + started := time.Now() warn := func(msg string) { _, _ = fmt.Fprintln(streams.Err, "warning: "+msg) } @@ -70,11 +138,45 @@ func runAudit(ctx context.Context, streams *Streams, opts *auditOptions) error { return err } + // Usage evidence is catalog and view/function source, never a user row. A + // failure to read it costs signal — fewer hot-column findings — never + // correctness, so it degrades to a warning like it does in discover. + var evidence sqlprobe.Evidence + if probed, err := sqlprobe.Probe(ctx, pool, cat.Schemas); err != nil { + warn("usage evidence skipped: " + err.Error()) + } else { + evidence = *probed + } + + coverage := cat.Coverage + coverage.PgStatStatements = evidence.StatementsAvailable + + findings := audit.Findings(cat.Schemas, audit.Options{ + Joins: evidence.Joins, + Predicates: evidence.Predicates, + Extensions: cat.Extensions, + RecurrenceMin: opts.recurrenceMin, + }) + + if !opts.noProbeKeys { + findings, err = probeMissingKeys(ctx, pool, cat.Schemas, findings, opts, &coverage, warn) + if err != nil { + return err + } + + if streams.Interactive { + detection := naming.Detect(cat.Schemas) + if err := resolveUnconfirmedKeys(ctx, streams, pool, cat.Schemas, findings, detection, opts); err != nil { + return err + } + } + } + version, _, _ := buildinfo.Resolve() - result := model.NewResult(version, "", time.Now().UTC(), cat.Coverage) + result := model.NewResult(version, "", time.Now().UTC(), coverage) result.ServerVersion = pool.ServerVersion() result.Schemas = cat.Schemas - result.Findings = audit.Findings(cat.Schemas) + result.Findings = findings result.Duration = time.Since(started) if opts.out != "" { @@ -92,3 +194,392 @@ func runAudit(ctx context.Context, streams *Streams, opts *auditOptions) error { } return report.Terminal(streams.Out, result, streams.Emphasis()) } + +// probeMissingKeys confirms, by counting rows, a candidate key for every +// missing_primary_key finding that has no unique to promote. A table above +// the configured row ceiling is left unprobed and recorded in coverage +// instead: this is the only place audit reads table data, and it stays +// bounded by default. +func probeMissingKeys(ctx context.Context, pool *db.Pool, schemas []model.Schema, findings []model.Finding, + opts *auditOptions, coverage *model.Coverage, warn func(string), +) ([]model.Finding, error) { + for i := range findings { + f := &findings[i] + if f.Kind != model.FindingMissingPrimaryKey || f.Suggestion == nil || + f.Suggestion.Kind != model.SuggestCreatePrimaryKey { + continue + } + + table, ok := tableByRef(schemas, f.Object) + if !ok { + continue + } + + rows, known := table.Stats.EstimatedRowCount() + if !known || rows > opts.probeKeysMaxRows { + coverage.KeyProbesSkipped = append(coverage.KeyProbesSkipped, model.SkippedKeyProbe{ + Table: f.Object, + Reason: "exceeds --probe-keys-max-rows", + }) + continue + } + + candidates := candidateKeys(table, maxKeyProbeCandidatesPerTable) + if len(candidates) == 0 { + continue + } + + results, err := validate.ProbeUniqueness(ctx, pool, table, candidates, opts.connection.statementTimeout) + if err != nil { + warn("key probe skipped for " + f.Object + ": " + err.Error()) + continue + } + + applyKeyProbeResults(f, results) + } + + return findings, nil +} + +// tableByRef looks up a table by its schema-qualified reference, the same +// string missing_primary_key uses as Finding.Object. +func tableByRef(schemas []model.Schema, ref string) (model.Table, bool) { + for _, s := range schemas { + for _, t := range s.Tables { + if t.Ref() == ref { + return t, true + } + } + } + return model.Table{}, false +} + +// candidateKeys names catalog-only candidate column sets to test for +// uniqueness on a table that has no promotable unique. It prefers the columns +// of an existing non-unique index that are all NOT NULL — the schema already +// grouped them for some reason — and falls back to each individual NOT NULL +// column in declaration order. Either way this only narrows what gets probed: +// confirmation always comes from counting rows, never from this heuristic. +func candidateKeys(t model.Table, budget int) [][]string { + var out [][]string + + for _, idx := range t.Indexes { + if idx.Unique || idx.Primary || len(idx.Columns) == 0 { + continue + } + if allColumnsNotNull(t, idx.Columns) { + out = append(out, idx.Columns) + } + } + + if len(out) == 0 { + for _, c := range t.Columns { + if !c.Nullable { + out = append(out, []string{c.Name}) + } + } + } + + if len(out) > budget { + out = out[:budget] + } + return out +} + +func allColumnsNotNull(t model.Table, columns []string) bool { + for _, name := range columns { + col, ok := t.Column(name) + if !ok || col.Nullable { + return false + } + } + return true +} + +// applyKeyProbeResults costures the probe verdict into the finding's +// suggestion. Columns are only ever populated on a confirmed key: a +// candidate that failed to confirm — whether proven non-unique or merely +// timed out — must never be presented as a named hypothesis, since a reader +// cannot tell those two outcomes apart from the columns alone. +func applyKeyProbeResults(f *model.Finding, results []validate.KeyProbeResult) { + for _, r := range results { + if r.Verdict == model.KeyProbeConfirmed { + f.Suggestion.Columns = r.Columns + f.Suggestion.KeyProbe = model.KeyProbeConfirmed + return + } + } + + f.Suggestion.KeyProbe = model.KeyProbeUnverified + if len(results) > 0 { + f.Suggestion.Note = fmt.Sprintf("tried %d candidate key(s); none confirmed unique", len(results)) + } +} + +// unresolvedKey pairs a missing_primary_key finding probeMissingKeys left +// unconfirmed with the table it is about and, when the table qualifies for +// one, an untested composite candidate built from its own single-column +// foreign keys. +type unresolvedKey struct { + finding *model.Finding + table model.Table + fkCandidate []string +} + +// resolveUnconfirmedKeys asks, at most once per run, what to do about every +// missing_primary_key finding probeMissingKeys left without a confirmed key. +// This is a schema-wide decision, not a per-table one: the operator answers +// for every unresolved table at once, and a synthetic column is always named +// from the convention the schema's own declared keys already establish — +// never typed, the same way any other table in the schema would be named. +// It must only be called when streams.Interactive is true: this is the one +// place audit blocks on user input, and the caller gates that already. +func resolveUnconfirmedKeys(ctx context.Context, streams *Streams, pool *db.Pool, schemas []model.Schema, + findings []model.Finding, detection model.NamingDetection, opts *auditOptions, +) error { + var pending []unresolvedKey + for i := range findings { + f := &findings[i] + if f.Kind != model.FindingMissingPrimaryKey || f.Suggestion == nil || + f.Suggestion.Kind != model.SuggestCreatePrimaryKey || f.Suggestion.KeyProbe == model.KeyProbeConfirmed { + continue + } + + table, ok := tableByRef(schemas, f.Object) + if !ok { + continue + } + + var fkCandidate []string + if rows, known := table.Stats.EstimatedRowCount(); !known || rows <= opts.probeKeysMaxRows { + fkCandidate, _ = fkKeyCandidate(table, candidateKeys(table, maxKeyProbeCandidatesPerTable)) + } + + pending = append(pending, unresolvedKey{finding: f, table: table, fkCandidate: fkCandidate}) + } + + if len(pending) == 0 { + return nil + } + + withComposite := 0 + for _, p := range pending { + if len(p.fkCandidate) > 0 { + withComposite++ + } + } + pkName := resolvePKName(detection.PrimaryKeyNames) + + _, _ = fmt.Fprint(streams.Err, formatKeyResolutionSummary(len(pending), withComposite, detection, pkName)) + + if withComposite == 0 && pkName == "" { + // Nothing to recommend either way: no composite candidate anywhere in + // scope, and no naming convention to name a synthetic column from. + // Asking would only ever offer skip, so there is nothing to ask. + return nil + } + + action, err := promptKeyResolution(streams, withComposite > 0, pkName) + if err != nil { + return err + } + + switch action { + case keyResolutionComposite: + for _, p := range pending { + if len(p.fkCandidate) == 0 { + continue + } + results, err := validate.ProbeUniqueness(ctx, pool, p.table, [][]string{p.fkCandidate}, opts.connection.statementTimeout) + if err != nil { + _, _ = fmt.Fprintln(streams.Err, "key probe skipped for "+p.finding.Object+": "+err.Error()) + continue + } + applyKeyProbeResults(p.finding, results) + } + case keyResolutionSynthetic: + note := fmt.Sprintf("schema convention: %q names the primary key elsewhere in the schema", pkName) + if len(detection.PrimaryKeyNames) > 0 { + top := detection.PrimaryKeyNames[0] + note = fmt.Sprintf("schema convention: %q names the primary key in %d of %d single-column-PK tables (%.0f%%)%s", + pkName, top.Occurrences, detection.SinglePKTables, top.Share*100, exampleSuffix(top.Examples)) + } + for _, p := range pending { + applySyntheticKey(p.finding, pkName, note) + } + } + + return nil +} + +// resolvePKName returns the schema's most common single-column primary key +// name, or "" when the schema has none to offer. detection.PrimaryKeyNames is +// already ranked strongest-first by profile.Detect; whatever tops it is what +// every other table in the schema is already named after, so there is +// nothing left to ask about the name itself. +func resolvePKName(evidence []model.NamingEvidence) string { + if len(evidence) == 0 { + return "" + } + return evidence[0].Affix +} + +// fkKeyCandidate names, for a table with two or more single-column declared +// foreign keys, the combination of those columns as a composite key +// candidate — the shape a join or association table's real key usually +// takes, and the one candidateKeys cannot see: it only looks at columns an +// existing index already groups, and a table missing its primary key +// commonly has no index over that pair either, which is often why the key is +// missing in the first place. It returns false when there are fewer than +// two, or when the exact combination was already tried. +func fkKeyCandidate(t model.Table, alreadyTried [][]string) ([]string, bool) { + var columns []string + for _, fk := range t.ForeignKeys { + if len(fk.Columns) == 1 { + columns = append(columns, fk.Columns[0]) + } + } + if len(columns) < 2 { + return nil, false + } + + for _, tried := range alreadyTried { + if sameColumnSet(tried, columns) { + return nil, false + } + } + return columns, true +} + +func sameColumnSet(a, b []string) bool { + if len(a) != len(b) { + return false + } + seen := make(map[string]bool, len(a)) + for _, c := range a { + seen[c] = true + } + for _, c := range b { + if !seen[c] { + return false + } + } + return true +} + +// applySyntheticKey turns a missing_primary_key finding into a synthetic +// column suggestion. It never touches KeyProbe: a brand-new column's +// uniqueness does not depend on any row that already exists, so there is +// nothing for a probe to confirm. +func applySyntheticKey(f *model.Finding, column, note string) { + f.Suggestion = &model.Suggestion{ + Kind: model.SuggestSyntheticPrimaryKey, + Columns: []string{column}, + Note: note, + } +} + +// exampleSuffix renders the objects a NamingEvidence was read from, so a +// convention cited by audit — applied on its own or offered in a prompt — +// can be checked against the schema instead of taken on faith. +func exampleSuffix(examples []string) string { + if len(examples) == 0 { + return "" + } + return " (e.g. " + strings.Join(examples, ", ") + ")" +} + +// formatKeyResolutionSummary renders the one-time, schema-wide picture the +// operator sees before being asked anything: how many tables are still +// unresolved, how many of those have an untested composite candidate, and +// what the schema itself says a primary key is usually called. It is printed +// once per run, never per table. +func formatKeyResolutionSummary(pending, withComposite int, detection model.NamingDetection, pkName string) string { + var b strings.Builder + fmt.Fprintf(&b, "\n%d table(s) have no confirmed primary key.\n", pending) + if withComposite > 0 { + fmt.Fprintf(&b, " %d of them have an untested composite candidate from their own foreign keys.\n", withComposite) + } + + if pkName == "" { + b.WriteString(" no schema-wide convention detected for a primary key name.\n") + } else { + top := detection.PrimaryKeyNames[0] + fmt.Fprintf(&b, " schema convention for a primary key name: %q (%d of %d single-column-PK tables, %.0f%%)%s\n", + pkName, top.Occurrences, detection.SinglePKTables, top.Share*100, exampleSuffix(top.Examples)) + } + return b.String() +} + +// keyResolutionAction is what the operator's answer to the key resolution +// prompt resolved to. It applies to every unresolved table in the run at +// once — this is a schema-wide decision, not a per-table one. +type keyResolutionAction int + +const ( + keyResolutionSkip keyResolutionAction = iota + keyResolutionComposite + keyResolutionSynthetic +) + +// formatKeyResolutionPrompt renders the menu, adapted to what is actually on +// offer: the composite option only appears when at least one pending table +// has a candidate, and the synthetic option only when the schema has a +// convention to name it from. Skip is always available, since it is what +// happens today when nothing is confirmed. +func formatKeyResolutionPrompt(compositeAvailable bool, pkName string) string { + var b strings.Builder + if compositeAvailable { + b.WriteString(" [a] recommend a composite primary key wherever a candidate exists\n") + } + if pkName != "" { + fmt.Fprintf(&b, " [b] recommend a new %q primary key column for the rest\n", pkName) + } + b.WriteString(" [enter] skip these tables\n> ") + return b.String() +} + +// parseKeyResolutionAnswer turns one line of operator input into a +// resolution action, with no I/O of its own — the shape that makes it +// testable without a terminal. ok is false when the line matches neither the +// available options nor a skip, which is the caller's signal to say so and +// ask again rather than silently guessing. +func parseKeyResolutionAnswer(line string, compositeAvailable bool, pkName string) (action keyResolutionAction, ok bool) { + line = strings.TrimSpace(line) + if line == "" { + return keyResolutionSkip, true + } + + switch { + case compositeAvailable && strings.EqualFold(line, "a"): + return keyResolutionComposite, true + case pkName != "" && strings.EqualFold(line, "b"): + return keyResolutionSynthetic, true + } + return keyResolutionSkip, false +} + +// promptKeyResolution asks the key resolution question, reprompting on an +// answer that is not recognized instead of silently treating it as a skip — +// a mistyped character is not the same as a deliberate skip, and this is the +// only place in a run the operator gets to say which one it was. Stdin +// closing outright resolves to skip: ReadString then returns an empty line +// alongside io.EOF, which parses as skip on its own, so there is nothing +// further to special-case. +func promptKeyResolution(streams *Streams, compositeAvailable bool, pkName string) (keyResolutionAction, error) { + reader := bufio.NewReader(streams.In) + prompt := formatKeyResolutionPrompt(compositeAvailable, pkName) + + for { + _, _ = fmt.Fprint(streams.Err, prompt) + line, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return keyResolutionSkip, fmt.Errorf("reading key resolution answer: %w", err) + } + + if action, ok := parseKeyResolutionAnswer(line, compositeAvailable, pkName); ok { + return action, nil + } + _, _ = fmt.Fprintln(streams.Err, "invalid answer") + } +} diff --git a/internal/cli/audit_efficiency_integration_test.go b/internal/cli/audit_efficiency_integration_test.go new file mode 100644 index 0000000..7c87ec8 --- /dev/null +++ b/internal/cli/audit_efficiency_integration_test.go @@ -0,0 +1,351 @@ +//go:build integration + +package cli_test + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + "github.com/lvcas-dotcom/pgfathom/internal/cli" + "github.com/lvcas-dotcom/pgfathom/internal/model" + "github.com/lvcas-dotcom/pgfathom/internal/report" + "github.com/lvcas-dotcom/pgfathom/internal/testutil" +) + +// runAuditJSON runs `audit --format json` against dsn with the given extra +// flags and decodes the result. +func runAuditJSON(t *testing.T, dsn string, extra ...string) *model.Result { + t.Helper() + + var out, errOut bytes.Buffer + streams := &cli.Streams{Out: &out, Err: &errOut, In: strings.NewReader("")} + + args := append([]string{"audit", "--format", "json", "--dsn", dsn, "--color", "never"}, extra...) + if code := cli.Run(args, streams); code != 0 { + t.Fatalf("exit code %d, stderr:\n%s", code, errOut.String()) + } + + var res model.Result + if err := json.Unmarshal(out.Bytes(), &res); err != nil { + t.Fatalf("decoding JSON output: %v", err) + } + return &res +} + +func findingsOfKind(r *model.Result, kind model.FindingKind) []model.Finding { + var out []model.Finding + for _, f := range r.Findings { + if f.Kind == kind { + out = append(out, f) + } + } + return out +} + +func findingByObject(t *testing.T, r *model.Result, object string) model.Finding { + t.Helper() + for _, f := range r.Findings { + if f.Object == object { + return f + } + } + t.Fatalf("no finding for %s among %+v", object, r.Findings) + return model.Finding{} +} + +// TestMissingPrimaryKeyPromotedFromUnique covers the cheap, catalog-only path: +// a table with no PK but a UNIQUE NOT NULL constraint is offered promotion, +// with no data read required to prove it. +func TestMissingPrimaryKeyPromotedFromUnique(t *testing.T) { + dsn := testutil.Postgres(t, "missing_pk_promotable") + res := runAuditJSON(t, dsn) + + findings := findingsOfKind(res, model.FindingMissingPrimaryKey) + if len(findings) != 1 { + t.Fatalf("got %d missing_primary_key findings, want 1: %+v", len(findings), res.Findings) + } + + s := findings[0].Suggestion + if s == nil || s.Kind != model.SuggestPromoteUnique { + t.Fatalf("suggestion = %+v, want promote_unique", s) + } + if len(s.Columns) != 1 || s.Columns[0] != "cpf" { + t.Errorf("suggestion columns = %v, want [cpf]", s.Columns) + } + if len(res.Coverage.KeyProbesSkipped) != 0 { + t.Errorf("a promotable unique never needs a data probe: coverage = %+v", res.Coverage.KeyProbesSkipped) + } +} + +// TestMissingCompositeKeyConfirmedByProbe covers the one place audit reads +// table data: a full-scan count confirms a composite key the catalog alone +// could not prove — and, in the same fixture, refuses to confirm a candidate +// that actually has a duplicate. +func TestMissingCompositeKeyConfirmedByProbe(t *testing.T) { + dsn := testutil.Postgres(t, "missing_pk_composite") + res := runAuditJSON(t, dsn) + + if got := findingsOfKind(res, model.FindingMissingPrimaryKey); len(got) != 2 { + t.Fatalf("got %d missing_primary_key findings, want 2 (one per table): %+v", len(got), res.Findings) + } + + clean := findingByObject(t, res, "public.item_pedido") + s := clean.Suggestion + if s == nil || s.Kind != model.SuggestCreatePrimaryKey { + t.Fatalf("item_pedido suggestion = %+v, want create_primary_key", s) + } + if s.KeyProbe != model.KeyProbeConfirmed { + t.Fatalf("item_pedido key_probe = %q, want confirmed: the planted data has no duplicate pair", s.KeyProbe) + } + want := map[string]bool{"pedido_id": true, "sequencia": true} + if len(s.Columns) != 2 || !want[s.Columns[0]] || !want[s.Columns[1]] { + t.Errorf("item_pedido suggestion columns = %v, want pedido_id and sequencia", s.Columns) + } + + // pagamento_parcela carries a planted duplicate on the same shape of + // candidate: the probe must not confirm it, and must not name columns for + // a candidate it could not prove. + dup := findingByObject(t, res, "public.pagamento_parcela") + if ds := dup.Suggestion; ds == nil || ds.KeyProbe != model.KeyProbeUnverified || len(ds.Columns) != 0 { + t.Errorf("pagamento_parcela suggestion = %+v, want unverified with no columns: a real duplicate exists", dup.Suggestion) + } +} + +// TestNoProbeKeysFlagStaysDataFree proves --no-probe-keys really disables the +// only data read audit ever performs: the same fixture that gets a confirmed +// composite key above must come back unconfirmed here, with no columns named. +func TestNoProbeKeysFlagStaysDataFree(t *testing.T) { + dsn := testutil.Postgres(t, "missing_pk_composite") + res := runAuditJSON(t, dsn, "--no-probe-keys") + + clean := findingByObject(t, res, "public.item_pedido") + s := clean.Suggestion + if s == nil || s.KeyProbe != "" || len(s.Columns) != 0 { + t.Errorf("suggestion = %+v, want no probe verdict and no columns with --no-probe-keys", s) + } +} + +// TestProbeKeysMaxRowsSkipsLargeTables proves the row ceiling is honored: with +// it set below the fixtures' table sizes, the probe never runs and the skip is +// recorded in coverage rather than silently absent. +func TestProbeKeysMaxRowsSkipsLargeTables(t *testing.T) { + dsn := testutil.Postgres(t, "missing_pk_composite") + res := runAuditJSON(t, dsn, "--probe-keys-max-rows", "0") + + clean := findingByObject(t, res, "public.item_pedido") + if s := clean.Suggestion; s == nil || s.KeyProbe != "" { + t.Errorf("suggestion = %+v, want no probe verdict above the row ceiling", clean.Suggestion) + } + if len(res.Coverage.KeyProbesSkipped) != 2 { + t.Fatalf("coverage.key_probes_skipped = %v, want both tables listed", res.Coverage.KeyProbesSkipped) + } +} + +// TestHotColumnFoundFromViewAndFunction covers the join-and-filter evidence +// path: a column two different objects name, with no index leading it. +func TestHotColumnFoundFromViewAndFunction(t *testing.T) { + dsn := testutil.Postgres(t, "hot_column_unindexed") + res := runAuditJSON(t, dsn) + + findings := findingsOfKind(res, model.FindingUnindexedHotColumn) + if len(findings) != 1 { + t.Fatalf("got %d unindexed_hot_column findings, want 1: %+v", len(findings), res.Findings) + } + + f := findings[0] + if !strings.Contains(f.Object, "centro_custo_id") { + t.Errorf("object = %q, want it to name centro_custo_id", f.Object) + } + if f.Suggestion == nil || f.Suggestion.IndexMethod != "btree" { + t.Errorf("suggestion = %+v, want btree", f.Suggestion) + } +} + +// TestRecurrenceMinFiltersOutSingleMention proves the threshold is enforced: +// raising it above the fixture's two mentions must silence the finding. +func TestRecurrenceMinFiltersOutSingleMention(t *testing.T) { + dsn := testutil.Postgres(t, "hot_column_unindexed") + res := runAuditJSON(t, dsn, "--recurrence-min", "3") + + if findings := findingsOfKind(res, model.FindingUnindexedHotColumn); len(findings) != 0 { + t.Errorf("got %d hot-column findings above the fixture's recurrence, want 0: %+v", len(findings), findings) + } +} + +// TestJsonbContainmentRecommendsGIN covers the type-gated index method: jsonb +// containment gets GIN with no operator class, since jsonb_ops is the default. +func TestJsonbContainmentRecommendsGIN(t *testing.T) { + dsn := testutil.Postgres(t, "jsonb_containment") + res := runAuditJSON(t, dsn) + + findings := findingsOfKind(res, model.FindingUnindexedHotColumn) + if len(findings) != 1 { + t.Fatalf("got %d unindexed_hot_column findings, want 1: %+v", len(findings), res.Findings) + } + + s := findings[0].Suggestion + if s == nil || s.IndexMethod != "gin" || s.IndexOpclass != "" { + t.Errorf("suggestion = %+v, want gin with no operator class for jsonb", s) + } +} + +// commentedStatement finds the one commented line in content that contains +// every one of want, strips the "-- " prefix and the trailing ";", and fails +// the test if no line matches. Mirrors the parse check +// TestSuggestedIndexesArtifactParsesUnderExplain already runs for a single +// CREATE INDEX CONCURRENTLY line, generalized to pick one of several +// candidate lines by content. +func commentedStatement(t *testing.T, content string, want ...string) string { + t.Helper() + + for _, line := range strings.Split(content, "\n") { + trimmed := strings.TrimPrefix(strings.TrimSpace(line), "-- ") + matches := true + for _, w := range want { + if !strings.Contains(trimmed, w) { + matches = false + break + } + } + if matches { + return strings.TrimSuffix(trimmed, ";") + } + } + t.Fatalf("expected a commented line containing %v:\n%s", want, content) + return "" +} + +// TestSuggestedKeysArtifactPromotesLiveUnique proves the three-step promotion +// — build a fresh index CONCURRENTLY, promote it, drop the old UNIQUE +// constraint — is not just well-formed but actually runs, in order, against +// the database that produced it. CONCURRENTLY cannot run inside a +// transaction, so each statement executes on its own, never concatenated. +func TestSuggestedKeysArtifactPromotesLiveUnique(t *testing.T) { + dsn := testutil.Postgres(t, "missing_pk_promotable") + dir := t.TempDir() + + runCommand(t, "audit", "--dsn", dsn, "--out", dir) + + content := readArtifact(t, dir, report.FileSuggestedKeys) + steps := []string{ + commentedStatement(t, content, "CREATE UNIQUE INDEX CONCURRENTLY"), + commentedStatement(t, content, "ADD PRIMARY KEY USING INDEX"), + commentedStatement(t, content, "DROP CONSTRAINT"), + } + + conn := connect(t, dsn) + for _, stmt := range steps { + if _, err := conn.Exec(context.Background(), stmt); err != nil { + t.Fatalf("promotion step does not run as generated: %v\n--- statement ---\n%s", err, stmt) + } + } + + var hasPK bool + err := conn.QueryRow(context.Background(), + `SELECT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'cadastro_pessoa'::regclass AND contype = 'p')`, + ).Scan(&hasPK) + if err != nil { + t.Fatalf("checking for the promoted key: %v", err) + } + if !hasPK { + t.Error("running the artifact must leave cadastro_pessoa with a primary key") + } +} + +// TestSuggestedIndexesArtifactParsesUnderExplain proves the commented +// CONCURRENTLY statement is syntactically real SQL, not just plausible text — +// the same discipline TestExoticNamesSurviveGeneration applies to discover. +func TestSuggestedIndexesArtifactParsesUnderExplain(t *testing.T) { + dsn := testutil.Postgres(t, "hot_column_unindexed") + dir := t.TempDir() + + runCommand(t, "audit", "--dsn", dsn, "--out", dir) + + content := readArtifact(t, dir, report.FileSuggestedIndexes) + + var stmt string + for _, line := range strings.Split(content, "\n") { + trimmed := strings.TrimPrefix(strings.TrimSpace(line), "-- ") + if strings.HasPrefix(trimmed, "CREATE INDEX CONCURRENTLY") { + stmt = strings.TrimSuffix(trimmed, ";") + break + } + } + if stmt == "" { + t.Fatalf("expected a commented CREATE INDEX CONCURRENTLY line:\n%s", content) + } + + // CONCURRENTLY cannot run inside a transaction, and EXPLAIN cannot plan + // DDL either; a plain, separate connection running it for real is the + // only honest parse check available, and it also proves the artifact's + // own claim about the fixture. + conn := connect(t, dsn) + if _, err := conn.Exec(context.Background(), stmt); err != nil { + t.Errorf("the suggested index does not run as written: %v\n--- statement ---\n%s", err, stmt) + } +} + +// TestEfficiencyFindingsNeverLeakUserData extends the artifact leak scan to +// the two new files: the probe reads real rows, and the discipline that keeps +// values from ever reaching output has to hold there too. +func TestEfficiencyFindingsNeverLeakUserData(t *testing.T) { + cases := []struct { + fixture string + files []string + }{ + {"missing_pk_promotable", []string{report.FileSuggestedKeys}}, + {"missing_pk_composite", []string{report.FileSuggestedKeys}}, + {"hot_column_unindexed", []string{report.FileSuggestedIndexes}}, + {"jsonb_containment", []string{report.FileSuggestedIndexes}}, + } + + for _, tc := range cases { + t.Run(tc.fixture, func(t *testing.T) { + dsn := testutil.Postgres(t, tc.fixture) + dir := t.TempDir() + + stdout, stderr := runCommand(t, "audit", "--dsn", dsn, "--out", dir, "--format", "json", "--log-level", "debug") + + artifacts := map[string]string{"stdout": stdout, "stderr": stderr} + for _, file := range tc.files { + artifacts[file] = readArtifact(t, dir, file) + } + testutil.AssertNoLeak(t, artifacts) + }) + } +} + +// pgvectorImage has the extension preinstalled; the standard postgres image +// used everywhere else in this suite does not carry it. +const pgvectorImage = "pgvector/pgvector:pg13" + +// TestVectorDistanceRecommendsHNSW covers the extension-gated method end to +// end. It needs an image most CI environments will not have cached, so a +// container that fails to start skips the test instead of failing the suite. +func TestVectorDistanceRecommendsHNSW(t *testing.T) { + dsn, ok := testutil.TryPostgresImageDSN(t, pgvectorImage, "pgvector_unindexed") + if !ok { + t.Skip("pgvector image not available in this environment") + } + + stdout, stderr := runCommand(t, "audit", "--dsn", dsn, "--format", "json", "--log-level", "debug") + testutil.AssertNoLeak(t, map[string]string{"stdout": stdout, "stderr": stderr}) + + var res model.Result + if err := json.Unmarshal([]byte(stdout), &res); err != nil { + t.Fatalf("decoding JSON output: %v", err) + } + + findings := findingsOfKind(&res, model.FindingUnindexedHotColumn) + if len(findings) != 1 { + t.Fatalf("got %d unindexed_hot_column findings, want 1: %+v", len(findings), res.Findings) + } + + s := findings[0].Suggestion + if s == nil || s.IndexMethod != "hnsw" || s.IndexOpclass != "vector_l2_ops" { + t.Errorf("suggestion = %+v, want hnsw/vector_l2_ops with pgvector installed", s) + } +} diff --git a/internal/cli/audit_interactive_integration_test.go b/internal/cli/audit_interactive_integration_test.go new file mode 100644 index 0000000..0554b79 --- /dev/null +++ b/internal/cli/audit_interactive_integration_test.go @@ -0,0 +1,148 @@ +//go:build integration + +package cli_test + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/lvcas-dotcom/pgfathom/internal/cli" + "github.com/lvcas-dotcom/pgfathom/internal/model" + "github.com/lvcas-dotcom/pgfathom/internal/testutil" +) + +// runAuditJSONInteractive is runAuditJSON with Interactive forced true and a +// canned answer feed — the shape the one, schema-wide prompt +// resolveUnconfirmedKeys asks reads stdin through. It returns stderr too, +// since that is where the prompt itself, not the result, has to land. +func runAuditJSONInteractive(t *testing.T, dsn, answers string, extra ...string) (*model.Result, string) { + t.Helper() + + var out, errOut bytes.Buffer + streams := &cli.Streams{Out: &out, Err: &errOut, In: strings.NewReader(answers), Interactive: true} + + args := append([]string{"audit", "--format", "json", "--dsn", dsn, "--color", "never"}, extra...) + if code := cli.Run(args, streams); code != 0 { + t.Fatalf("exit code %d, stderr:\n%s", code, errOut.String()) + } + + var res model.Result + if err := json.Unmarshal(out.Bytes(), &res); err != nil { + t.Fatalf("decoding JSON output: %v\nstderr:\n%s", err, errOut.String()) + } + return &res, errOut.String() +} + +// TestInteractiveCompositeKeyChosenGlobally covers the gap candidateKeys +// cannot see on its own: a bridge table whose two single-column foreign +// keys, with no index over the pair, are its real key. Answering "a" at the +// one schema-wide prompt is what makes resolveUnconfirmedKeys try that +// combination — the automatic path never does. +func TestInteractiveCompositeKeyChosenGlobally(t *testing.T) { + dsn := testutil.Postgres(t, "missing_pk_fk_bridge") + + res, stderr := runAuditJSONInteractive(t, dsn, "a\n") + + f := findingByObject(t, res, "public.pedido_produto") + s := f.Suggestion + if s == nil || s.Kind != model.SuggestCreatePrimaryKey { + t.Fatalf("suggestion = %+v, want create_primary_key", s) + } + if s.KeyProbe != model.KeyProbeConfirmed { + t.Fatalf("key_probe = %q, want confirmed: (pedido_id, produto_id) has no duplicate pair", s.KeyProbe) + } + want := map[string]bool{"pedido_id": true, "produto_id": true} + if len(s.Columns) != 2 || !want[s.Columns[0]] || !want[s.Columns[1]] { + t.Errorf("columns = %v, want pedido_id and produto_id", s.Columns) + } + if !strings.Contains(stderr, "table(s) have no confirmed primary key") { + t.Errorf("the summary must be printed once, before the prompt:\n%s", stderr) + } + if !strings.Contains(stderr, "untested composite candidate") { + t.Errorf("the summary must say how many tables have a composite candidate:\n%s", stderr) + } +} + +// TestInteractiveSyntheticColumnChosenGlobally covers the other branch of the +// same prompt: answering "b" names the synthetic column after the schema's +// own convention — never typed — with no data probe at all. +func TestInteractiveSyntheticColumnChosenGlobally(t *testing.T) { + dsn := testutil.Postgres(t, "missing_pk_fk_bridge") + + res, stderr := runAuditJSONInteractive(t, dsn, "b\n") + + f := findingByObject(t, res, "public.pedido_produto") + s := f.Suggestion + if s == nil || s.Kind != model.SuggestSyntheticPrimaryKey { + t.Fatalf("suggestion = %+v, want synthesize_primary_key", s) + } + if len(s.Columns) != 1 || s.Columns[0] != "idkey" { + t.Errorf("columns = %v, want [idkey]: the name pedido, produto and situacao already use", s.Columns) + } + if s.KeyProbe != "" { + t.Errorf("key_probe = %q, want empty: a synthetic column is never confirmed by data", s.KeyProbe) + } + if !strings.Contains(s.Note, "schema convention") { + t.Errorf("note = %q, want it to cite the schema convention the name came from", s.Note) + } + if !strings.Contains(stderr, "schema convention for a primary key name") { + t.Errorf("the summary must state the convention before asking:\n%s", stderr) + } +} + +// TestInteractiveEmptyAnswerSkips covers the third branch: an empty line +// leaves every unresolved finding exactly where the automatic path left it. +func TestInteractiveEmptyAnswerSkips(t *testing.T) { + dsn := testutil.Postgres(t, "missing_pk_fk_bridge") + + res, _ := runAuditJSONInteractive(t, dsn, "\n") + + f := findingByObject(t, res, "public.pedido_produto") + s := f.Suggestion + if s == nil || s.Kind != model.SuggestCreatePrimaryKey || len(s.Columns) != 0 { + t.Errorf("suggestion = %+v, want create_primary_key with no columns: skipping must not invent one", s) + } +} + +// TestInteractiveInvalidAnswerReprompts proves a mistyped answer is not +// silently treated as a skip: the run must say so and ask again, resolving +// on whatever valid answer eventually arrives. +func TestInteractiveInvalidAnswerReprompts(t *testing.T) { + dsn := testutil.Postgres(t, "missing_pk_fk_bridge") + + res, stderr := runAuditJSONInteractive(t, dsn, "z\nb\n") + + if !strings.Contains(stderr, "invalid answer") { + t.Errorf("a mistyped answer must be reported as invalid, not silently skipped:\n%s", stderr) + } + + f := findingByObject(t, res, "public.pedido_produto") + if s := f.Suggestion; s == nil || s.Kind != model.SuggestSyntheticPrimaryKey { + t.Errorf("suggestion = %+v, want the run to resolve on the valid answer that followed", f.Suggestion) + } +} + +// TestNonInteractiveNeverPrompts is the regression the whole feature is gated +// behind: the default Streams every other test in this package builds, with +// Interactive left at its zero value, must never write a prompt or block on +// stdin, even against a fixture that would otherwise trigger one. +func TestNonInteractiveNeverPrompts(t *testing.T) { + dsn := testutil.Postgres(t, "missing_pk_fk_bridge") + + stdout, stderr := runCommand(t, "audit", "--dsn", dsn, "--format", "json") + if strings.Contains(stderr, "table(s) have no confirmed primary key") { + t.Errorf("a non-interactive run must never print the resolution summary or prompt:\n%s", stderr) + } + + var res model.Result + if err := json.Unmarshal([]byte(stdout), &res); err != nil { + t.Fatalf("decoding JSON output: %v", err) + } + + f := findingByObject(t, &res, "public.pedido_produto") + if s := f.Suggestion; s == nil || s.Kind != model.SuggestCreatePrimaryKey || len(s.Columns) != 0 { + t.Errorf("suggestion = %+v, want create_primary_key with no columns when nothing answered it", s) + } +} diff --git a/internal/cli/audit_test.go b/internal/cli/audit_test.go new file mode 100644 index 0000000..87488c9 --- /dev/null +++ b/internal/cli/audit_test.go @@ -0,0 +1,138 @@ +package cli + +import ( + "testing" + + "github.com/lvcas-dotcom/pgfathom/internal/model" +) + +func TestExampleSuffix(t *testing.T) { + if got := exampleSuffix(nil); got != "" { + t.Errorf("exampleSuffix(nil) = %q, want empty: a convention with no evidence must not fabricate one", got) + } + if got := exampleSuffix([]string{"cliente", "pedido"}); got != " (e.g. cliente, pedido)" { + t.Errorf("exampleSuffix = %q, want the examples cited so the convention is checkable", got) + } +} + +func TestParseKeyResolutionAnswer(t *testing.T) { + tests := []struct { + name string + line string + compositeAvailable bool + pkName string + wantAction keyResolutionAction + wantOK bool + }{ + {"empty line skips", "", true, "idkey", keyResolutionSkip, true}, + {"whitespace-only line skips", " \n", true, "idkey", keyResolutionSkip, true}, + {"a chooses composite when available", "a\n", true, "idkey", keyResolutionComposite, true}, + {"A is case-insensitive", "A\n", true, "idkey", keyResolutionComposite, true}, + {"a is invalid when composite is not available", "a\n", false, "idkey", keyResolutionSkip, false}, + {"b chooses synthetic when a convention exists", "b\n", true, "idkey", keyResolutionSynthetic, true}, + {"B is case-insensitive", "B\n", false, "idkey", keyResolutionSynthetic, true}, + {"b is invalid when no convention was detected", "b\n", true, "", keyResolutionSkip, false}, + {"an unrecognized answer is invalid, not a skip", "x\n", true, "idkey", keyResolutionSkip, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + action, ok := parseKeyResolutionAnswer(tt.line, tt.compositeAvailable, tt.pkName) + if action != tt.wantAction { + t.Errorf("action = %v, want %v", action, tt.wantAction) + } + if ok != tt.wantOK { + t.Errorf("ok = %v, want %v", ok, tt.wantOK) + } + }) + } +} + +func TestResolvePKName(t *testing.T) { + t.Run("no evidence names nothing", func(t *testing.T) { + if name := resolvePKName(nil); name != "" { + t.Errorf("resolvePKName(nil) = %q, want empty", name) + } + }) + + t.Run("whatever tops the ranking is what the rest of the schema already uses", func(t *testing.T) { + name := resolvePKName([]model.NamingEvidence{ + {Affix: "idkey", Occurrences: 42, Share: 0.42}, + {Affix: "id", Occurrences: 38, Share: 0.38}, + }) + if name != "idkey" { + t.Errorf("resolvePKName = %q, want idkey: there is no separate confidence bar to clear", name) + } + }) +} + +func fkTable(name string, fkColumns ...string) model.Table { + fks := make([]model.ForeignKey, len(fkColumns)) + for i, c := range fkColumns { + fks[i] = model.ForeignKey{Columns: []string{c}, RefSchema: "public", RefTable: "other", RefColumns: []string{"idkey"}} + } + return model.Table{Schema: "public", Name: name, ForeignKeys: fks} +} + +func TestFKKeyCandidate(t *testing.T) { + t.Run("two single-column FKs form a candidate", func(t *testing.T) { + cols, ok := fkKeyCandidate(fkTable("pedido_item", "idkey_pedido", "idkey_produto"), nil) + if !ok { + t.Fatal("want a candidate") + } + if len(cols) != 2 || cols[0] != "idkey_pedido" || cols[1] != "idkey_produto" { + t.Errorf("cols = %v, want [idkey_pedido idkey_produto]", cols) + } + }) + + t.Run("a single FK is not enough", func(t *testing.T) { + if _, ok := fkKeyCandidate(fkTable("pedido_item", "idkey_pedido"), nil); ok { + t.Error("want no candidate with only one FK column") + } + }) + + t.Run("no FKs at all", func(t *testing.T) { + if _, ok := fkKeyCandidate(fkTable("staging"), nil); ok { + t.Error("want no candidate with no FKs") + } + }) + + t.Run("a multi-column FK does not count toward the composite", func(t *testing.T) { + tbl := model.Table{Schema: "public", Name: "t", ForeignKeys: []model.ForeignKey{ + {Columns: []string{"a", "b"}, RefTable: "other"}, + {Columns: []string{"c"}, RefTable: "third"}, + }} + if _, ok := fkKeyCandidate(tbl, nil); ok { + t.Error("want no candidate: only one single-column FK is available") + } + }) + + t.Run("already tried combination is not offered again", func(t *testing.T) { + tbl := fkTable("pedido_item", "idkey_pedido", "idkey_produto") + alreadyTried := [][]string{{"idkey_produto", "idkey_pedido"}} + if _, ok := fkKeyCandidate(tbl, alreadyTried); ok { + t.Error("want no candidate: the same set was already tried, in a different order") + } + }) +} + +func TestApplySyntheticKey(t *testing.T) { + f := &model.Finding{Kind: model.FindingMissingPrimaryKey, Suggestion: &model.Suggestion{ + Kind: model.SuggestCreatePrimaryKey, KeyProbe: model.KeyProbeUnverified, + }} + + applySyntheticKey(f, "idkey", "user-provided name") + + if f.Suggestion.Kind != model.SuggestSyntheticPrimaryKey { + t.Errorf("Kind = %v, want %v", f.Suggestion.Kind, model.SuggestSyntheticPrimaryKey) + } + if len(f.Suggestion.Columns) != 1 || f.Suggestion.Columns[0] != "idkey" { + t.Errorf("Columns = %v, want [idkey]", f.Suggestion.Columns) + } + if f.Suggestion.KeyProbe != "" { + t.Errorf("KeyProbe = %q, want empty: a synthetic column is never confirmed by data", f.Suggestion.KeyProbe) + } + if f.Suggestion.Note != "user-provided name" { + t.Errorf("Note = %q, want %q", f.Suggestion.Note, "user-provided name") + } +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 93ff86a..63a3fc2 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -101,6 +101,16 @@ func TestNoANSIWhenNotATerminal(t *testing.T) { } } +// TestInteractiveDefaultsFalse guards the gate every prompt in audit relies +// on: a Streams built by literal, the shape every test in this package uses, +// must never accidentally opt into a prompt. +func TestInteractiveDefaultsFalse(t *testing.T) { + streams := &Streams{Out: &bytes.Buffer{}, Err: &bytes.Buffer{}, In: strings.NewReader("")} + if streams.Interactive { + t.Error("Interactive must default to false for a Streams built without StdStreams") + } +} + func TestResolveColor(t *testing.T) { t.Run("explicit never wins over everything", func(t *testing.T) { if resolveEmphasis(ColorNever, nil) != report.NoEmphasis { diff --git a/internal/cli/output.go b/internal/cli/output.go index dd23384..32ea310 100644 --- a/internal/cli/output.go +++ b/internal/cli/output.go @@ -30,6 +30,13 @@ type Streams struct { In io.Reader + // Interactive reports whether both In and Out are a real terminal — never + // set from a flag. A command gates any prompt on this, not on a setting a + // script would have to remember to pass: piped output, redirected input, + // and CI never see one. Zero value is false, so a Streams built by literal + // in a test stays silent unless a test opts in explicitly. + Interactive bool + color report.Emphasis // progress is the decision about drawing a self-rewriting line on Err, @@ -40,6 +47,7 @@ type Streams struct { // StdStreams wires a Streams to the process. func StdStreams(mode ColorMode) *Streams { s := &Streams{Out: os.Stdout, Err: os.Stderr, In: os.Stdin} + s.Interactive = isTerminal(os.Stdin) && isTerminal(os.Stdout) s.color = resolveEmphasis(mode, os.Stdout) // Progress is drawn on Err and therefore decided against Err. A run whose diff --git a/internal/infer/generate.go b/internal/infer/generate.go index 266d7da..382a2f7 100644 --- a/internal/infer/generate.go +++ b/internal/infer/generate.go @@ -16,6 +16,11 @@ type Options struct { // MinScore is the cut below which a candidate is discarded with a reason. MinScore float64 + // MinNameSimilarity is the cut below which the lexical-similarity fallback + // never raises a candidate at all. Distinct from MinScore, and far more + // permissive by design — see DefaultMinNameSimilarity. + MinNameSimilarity float64 + // SmallTableRows is the size below which a target counts as a domain table // for the generic-name penalty. SmallTableRows int64 @@ -40,6 +45,13 @@ func (o Options) smallTableRows() int64 { return o.SmallTableRows } +func (o Options) minNameSimilarity() float64 { + if o.MinNameSimilarity <= 0 { + return DefaultMinNameSimilarity + } + return o.MinNameSimilarity +} + // SkipReason says why a possible target could not be used. type SkipReason string @@ -121,6 +133,7 @@ func Generate(schemas []model.Schema, opts Options) *Result { } index := buildTargetIndex(schemas, opts.Profile) + tables := flattenTables(schemas) for _, s := range schemas { for _, t := range s.Tables { @@ -130,7 +143,7 @@ func Generate(schemas []model.Schema, opts Options) *Result { if !eligible(t, col) { continue } - generateFor(res, index, t, col, opts) + generateFor(res, index, tables, t, col, opts) } } } @@ -187,7 +200,49 @@ type indexedTarget struct { origin profile.Origin } -func generateFor(res *Result, index map[string][]indexedTarget, t model.Table, col model.Column, opts Options) { +// flattenTables lists every table across every schema in scope, in the order +// Generate already walks them. The affix index is keyed by normalized form +// and cannot be searched by proximity — the similarity fallback needs a +// plain list to scan instead. +func flattenTables(schemas []model.Schema) []model.Table { + var tables []model.Table + for _, s := range schemas { + tables = append(tables, s.Tables...) + } + return tables +} + +// nameMatch is a table that answered a column's entity name, however it was +// found, paired with the name signal that match earns. +type nameMatch struct { + tgt target + signal model.Signal +} + +// resolveKeyTarget checks whether a named table can anchor a single-column +// candidate: it must carry a primary key, and that key must be a single +// column. Composite and missing keys are recorded as a Skip rather than +// dropped in silence — the relationship may well be real, and this is where a +// near miss stays legible. +func resolveKeyTarget(child model.KeyRef, targetName string, table model.Table) (tgt target, skip *Skip, ok bool) { + switch { + case len(table.PrimaryKey) > 1: + // The composite pass is the one that can reach this target, and it + // looks for the key's own columns rather than for this name. A note + // here is what keeps the gap between the two visible. + return target{}, &Skip{Child: child, Target: targetName, Reason: SkipArityMismatch}, false + case len(table.PrimaryKey) == 0: + return target{}, &Skip{Child: child, Target: targetName, Reason: SkipNoKey}, false + } + + pk, found := table.Column(table.PrimaryKey[0]) + if !found { + return target{}, nil, false + } + return target{table: table, pkColumn: pk}, nil, true +} + +func generateFor(res *Result, index map[string][]indexedTarget, tables []model.Table, t model.Table, col model.Column, opts Options) { entity := opts.Profile.EntityName(col.Name) if entity == "" { return @@ -195,65 +250,122 @@ func generateFor(res *Result, index map[string][]indexedTarget, t model.Table, c child := model.SingleKey(t.Schema, t.Name, col.Name) - // Collapse to one entry per table: a table can answer to several forms, and - // the strongest match is the one that counts. + var matches []nameMatch + if indexed := index[entity]; len(indexed) > 0 { + matches = resolveByAffix(res, indexed, child) + } else { + // The affix index found nothing at all for this entity — never when it + // found something and every hit was skipped for arity or a missing key, + // which already has its own Skip explaining why. Trying a second route + // at that point would risk confusing "why was this table ignored" with + // "why did this other one appear from nowhere". + matches = resolveBySimilarity(res, tables, entity, child, opts) + } + + finalizeMatches(res, matches, t, col, entity, opts) +} + +// resolveByAffix collapses the profile index to one entry per table — a table +// can answer to several forms, and the strongest match is the one that counts +// — then resolves each against its primary key. +func resolveByAffix(res *Result, indexed []indexedTarget, child model.KeyRef) []nameMatch { best := make(map[string]indexedTarget) - for _, it := range index[entity] { + for _, it := range indexed { key := it.table.Schema + "." + it.table.Name if prev, seen := best[key]; !seen || it.origin < prev.origin { best[key] = it } } - usable := make([]target, 0, len(best)) - origins := make(map[string]profile.Origin, len(best)) - names := make([]string, 0, len(best)) for name := range best { names = append(names, name) } sort.Strings(names) + matches := make([]nameMatch, 0, len(names)) for _, name := range names { it := best[name] - switch { - case len(it.table.PrimaryKey) > 1: - // The composite pass is the one that can reach this target, and it - // looks for the key's own columns rather than for this name. A note - // here is what keeps the gap between the two visible. - res.Skipped = append(res.Skipped, Skip{Child: child, Target: name, Reason: SkipArityMismatch}) - continue - case len(it.table.PrimaryKey) == 0: - res.Skipped = append(res.Skipped, Skip{Child: child, Target: name, Reason: SkipNoKey}) + tgt, skip, ok := resolveKeyTarget(child, name, it.table) + if skip != nil { + res.Skipped = append(res.Skipped, *skip) + } + if !ok { continue } - pk, ok := it.table.Column(it.table.PrimaryKey[0]) + matches = append(matches, nameMatch{tgt: tgt, signal: nameSignalFromOrigin(it.origin, it.table.Name)}) + } + return matches +} + +// resolveBySimilarity is the fallback the profile index cannot serve: it +// scans every table in scope by lexical proximity to the entity name instead +// of the profile's affix/plural forms. Only called by generateFor when the +// affix index found nothing. +func resolveBySimilarity(res *Result, tables []model.Table, entity string, child model.KeyRef, opts Options) []nameMatch { + type scored struct { + table model.Table + similarity float64 + } + + cutoff := opts.minNameSimilarity() + var above []scored + for _, table := range tables { + similarity := TrigramSimilarity(entity, table.Name) + if similarity >= cutoff { + above = append(above, scored{table: table, similarity: similarity}) + } + } + + // Deterministic order: strongest similarity first, ties broken by name — + // the same discipline sortCandidates applies to the final output. + sort.SliceStable(above, func(i, j int) bool { + if above[i].similarity != above[j].similarity { + return above[i].similarity > above[j].similarity + } + return above[i].table.Name < above[j].table.Name + }) + + matches := make([]nameMatch, 0, len(above)) + for _, s := range above { + name := s.table.Schema + "." + s.table.Name + + tgt, skip, ok := resolveKeyTarget(child, name, s.table) + if skip != nil { + res.Skipped = append(res.Skipped, *skip) + } if !ok { continue } - usable = append(usable, target{table: it.table, pkColumn: pk}) - origins[name] = it.origin + + matches = append(matches, nameMatch{tgt: tgt, signal: nameSignalFromSimilarity(s.table.Name, s.similarity)}) } + return matches +} +// finalizeMatches turns resolved name matches into scored candidates. Shared +// by every match source — profile affix or lexical fallback — because type +// compatibility, ambiguity and the rest of the signal set do not depend on +// how the name was found. +func finalizeMatches(res *Result, matches []nameMatch, t model.Table, col model.Column, entity string, opts Options) { // Ambiguity is kept rather than resolved by guesswork. Picking the largest // or the same-schema table would be a hunch dressed as a decision, and it // would hide from the user that there was any uncertainty at all. - ambiguous := len(usable) > 1 + ambiguous := len(matches) > 1 - for _, tgt := range usable { - match := CompareTypes(col.BaseType, tgt.pkColumn.BaseType) + for _, m := range matches { + match := CompareTypes(col.BaseType, m.tgt.pkColumn.BaseType) if !match.Compatible() { continue } - key := tgt.table.Schema + "." + tgt.table.Name - signals := buildSignals(t, col, tgt, origins[key], match, ambiguous, entity, opts) + signals := buildSignals(m.signal, t, col, m.tgt, match, ambiguous, entity, opts) res.Candidates = append(res.Candidates, model.Candidate{ - Child: child, - Parent: model.SingleKey(tgt.table.Schema, tgt.table.Name, tgt.pkColumn.Name), + Child: model.SingleKey(t.Schema, t.Name, col.Name), + Parent: model.SingleKey(m.tgt.table.Schema, m.tgt.table.Name, m.tgt.pkColumn.Name), Signals: signals, MetaScore: score(signals), Verdict: model.VerdictUnvalidated, @@ -262,21 +374,28 @@ func generateFor(res *Result, index map[string][]indexedTarget, t model.Table, c } } -func buildSignals(t model.Table, col model.Column, tgt target, origin profile.Origin, +func nameSignalFromOrigin(origin profile.Origin, tableName string) model.Signal { + if origin.Exact() { + return model.Signal{Kind: model.SigExactName, Weight: weightExactName, Detail: tableName} + } + return model.Signal{ + Kind: model.SigNormalizedName, Weight: weightNormalizedName, + Detail: tableName + " via " + origin.String(), + } +} + +func nameSignalFromSimilarity(tableName string, similarity float64) model.Signal { + return model.Signal{ + Kind: model.SigNameSimilarity, Weight: nameSimilarityWeight(similarity), + Detail: tableName + " via lexical similarity", + } +} + +func buildSignals(nameSignal model.Signal, t model.Table, col model.Column, tgt target, match TypeMatch, ambiguous bool, entity string, opts Options) []model.Signal { signals := make([]model.Signal, 0, 6) - - if origin.Exact() { - signals = append(signals, model.Signal{ - Kind: model.SigExactName, Weight: weightExactName, Detail: tgt.table.Name, - }) - } else { - signals = append(signals, model.Signal{ - Kind: model.SigNormalizedName, Weight: weightNormalizedName, - Detail: tgt.table.Name + " via " + origin.String(), - }) - } + signals = append(signals, nameSignal) if match == TypeIdentical { signals = append(signals, model.Signal{ diff --git a/internal/infer/generate_similarity_test.go b/internal/infer/generate_similarity_test.go new file mode 100644 index 0000000..d699aff --- /dev/null +++ b/internal/infer/generate_similarity_test.go @@ -0,0 +1,105 @@ +package infer_test + +import ( + "testing" + + "github.com/lvcas-dotcom/pgfathom/internal/model" +) + +func TestSimilarityFallbackGeneratesWhenAffixFindsNothing(t *testing.T) { + res := generate(t, schema( + tbl("operadorbasecalculo"), + tbl("basecalculo", col("operador_id")), + )) + + c, ok := find(res, "public.basecalculo.operador_id", "public.operadorbasecalculo.id") + if !ok { + t.Fatalf("expected candidate not generated; got %d survivors, %d discarded", + len(res.Candidates), len(res.Discarded)) + } + if !c.HasSignal(model.SigNameSimilarity) { + t.Error("operador_id matching operadorbasecalculo should carry the name-similarity signal") + } + if c.HasSignal(model.SigExactName) || c.HasSignal(model.SigNormalizedName) { + t.Error("a candidate raised by the similarity fallback should not also carry a profile-match signal") + } +} + +func TestSimilarityBelowCutoffGeneratesNothing(t *testing.T) { + res := generate(t, schema( + tbl("cliente"), + tbl("pedido", col("xyzabc_id")), + )) + + if _, ok := find(res, "public.pedido.xyzabc_id", "public.cliente.id"); ok { + t.Error("entity with no lexical overlap to any table should never raise a candidate") + } +} + +func TestAffixMatchSuppressesSimilarityFallback(t *testing.T) { + // clientefiel is lexically close to cliente but never checked: cliente + // itself already answers the profile index exactly, so the fallback is + // never evaluated for this column. + res := generate(t, schema( + tbl("cliente"), + tbl("clientefiel"), + tbl("pedido", col("cliente_id")), + )) + + c, ok := find(res, "public.pedido.cliente_id", "public.cliente.id") + if !ok { + t.Fatal("exact affix match should still generate a candidate") + } + if !c.HasSignal(model.SigExactName) { + t.Error("cliente_id matching cliente should carry the exact-name signal, unaffected by this change") + } + + if _, ok := find(res, "public.pedido.cliente_id", "public.clientefiel.id"); ok { + t.Error("similarity fallback must not run when the profile index already found a table") + } +} + +func TestSimilarityFallbackHandlesAmbiguity(t *testing.T) { + res := generate(t, schema( + tbl("fornecedorpj"), + tbl("fornecedorpf"), + tbl("pedido", col("fornecedor_id")), + )) + + pj, ok := find(res, "public.pedido.fornecedor_id", "public.fornecedorpj.id") + if !ok { + t.Fatal("expected candidate towards fornecedorpj not generated") + } + pf, ok := find(res, "public.pedido.fornecedor_id", "public.fornecedorpf.id") + if !ok { + t.Fatal("expected candidate towards fornecedorpf not generated") + } + + if !pj.HasSignal(model.SigAmbiguousTarget) || !pf.HasSignal(model.SigAmbiguousTarget) { + t.Error("both candidates from an ambiguous similarity match should carry the ambiguous-target signal") + } +} + +// TestSimilarityFallbackReproducesCorpusMiss mirrors, in the synthetic format +// the rest of this file already uses, the real gap documented in +// docs/PGFATHOM.md — atotramite.tptramite_idkey -> tramitetipo, abbreviated +// and reordered. The real column uses the _idkey suffix, which the embedded +// pt-br profile does not ship (it is learned per schema by naming detection, +// out of scope for this unit test) — substituting the recognized _id suffix +// isolates the similarity mechanism this change adds without depending on +// detection. +func TestSimilarityFallbackReproducesCorpusMiss(t *testing.T) { + res := generate(t, schema( + tbl("tramitetipo"), + tbl("atotramite", col("tptramite_id")), + )) + + c, ok := find(res, "public.atotramite.tptramite_id", "public.tramitetipo.id") + if !ok { + t.Fatalf("expected candidate not generated; got %d survivors, %d discarded", + len(res.Candidates), len(res.Discarded)) + } + if !c.HasSignal(model.SigNameSimilarity) { + t.Error("tptramite_id matching tramitetipo should carry the name-similarity signal") + } +} diff --git a/internal/infer/score.go b/internal/infer/score.go index 3480fcc..7a4a2cf 100644 --- a/internal/infer/score.go +++ b/internal/infer/score.go @@ -17,6 +17,12 @@ const ( weightCommentMention = 0.12 weightNotNull = 0.05 + // weightNameSimilarityMax is the ceiling for SigNameSimilarity, scaled by + // the measured similarity — see nameSimilarityWeight. It sits below + // weightNormalizedName even at the ceiling: lexical proximity carries no + // confirmed naming convention behind it, unlike a profile match. + weightNameSimilarityMax = 0.12 + // A join in real code outranks any name signal: it is usage, not // convention, and it is the only evidence that reaches relationships whose // names bear no resemblance. Views outrank function bodies, which the @@ -58,6 +64,15 @@ func arityWeight(n int) float64 { return w } +// nameSimilarityWeight scales SigNameSimilarity's weight linearly by the +// measured similarity, capped at weightNameSimilarityMax. A fixed weight +// would treat a borderline match at the generation cutoff the same as a near- +// exact one, which arityWeight already establishes is the wrong shape for a +// signal whose strength is a measured quantity rather than a fact. +func nameSimilarityWeight(similarity float64) float64 { + return weightNameSimilarityMax * similarity +} + // DefaultMinScore is the cut below which a candidate never reaches validation. // // It is an estimate, not a measurement: the honest calibration needs the @@ -70,6 +85,21 @@ const DefaultMinScore = 0.5 // table for the generic-name penalty. const DefaultSmallTableRows = 1000 +// DefaultMinNameSimilarity is the cut below which the lexical-similarity +// fallback never raises a candidate at all — distinct from DefaultMinScore, +// which cuts after a candidate already exists. +// +// Measured, not guessed, against the three corpus misses this fallback exists +// for (docs/PGFATHOM.md): operador/operadorbasecalculo scores 0.552, +// tptramite/tramitetipo scores 0.545, atorevogacao/ato scores 0.353. A value +// above the lowest of the three would silently defeat the feature's own +// motivating cases, so the default sits below all three with margin. It can +// afford to be permissive: Generate already generates liberally and cuts +// strictly (see its doc comment), and SigNameSimilarity's low weight means a +// candidate this fallback raises still needs help from other signals to +// survive DefaultMinScore. +const DefaultMinNameSimilarity = 0.30 + // score combines the signal weights, saturating at both ends. // // Free summation would make the range depend on how many signals happened to diff --git a/internal/infer/similarity.go b/internal/infer/similarity.go new file mode 100644 index 0000000..d24a48f --- /dev/null +++ b/internal/infer/similarity.go @@ -0,0 +1,46 @@ +package infer + +import "strings" + +// TrigramSimilarity is the Sørensen-Dice coefficient over sets of padded +// character trigrams, case-insensitive: twice the shared trigrams divided by +// the sum of both trigram counts. +// +// Padding follows pg_trgm's convention — two boundary characters before the +// string, one after — so a short name still yields trigrams instead of +// scoring artificially low for lack of them. Either side empty returns 0 +// without extracting anything: there is nothing to compare. +// +// This is the only string-distance metric in the package on purpose: it +// generalizes to the reordering and abbreviation the corpus actually shows +// (idkey_operador vs operadorbasecalculo), which a prefix-weighted metric +// like Jaro-Winkler would not reach any better, and adding a second metric +// without a measured case that needs it is exactly the kind of unmeasured +// surface this project avoids. +func TrigramSimilarity(a, b string) float64 { + if a == "" || b == "" { + return 0 + } + + ta := trigramSet(a) + tb := trigramSet(b) + + shared := 0 + for tri := range ta { + if tb[tri] { + shared++ + } + } + + return 2 * float64(shared) / float64(len(ta)+len(tb)) +} + +func trigramSet(s string) map[string]bool { + padded := " " + strings.ToLower(s) + " " + + set := make(map[string]bool) + for i := 0; i+3 <= len(padded); i++ { + set[padded[i:i+3]] = true + } + return set +} diff --git a/internal/infer/similarity_test.go b/internal/infer/similarity_test.go new file mode 100644 index 0000000..11e490b --- /dev/null +++ b/internal/infer/similarity_test.go @@ -0,0 +1,77 @@ +package infer_test + +import ( + "math" + "testing" + + "github.com/lvcas-dotcom/pgfathom/internal/infer" +) + +func TestTrigramSimilarity(t *testing.T) { + tests := []struct { + name string + a, b string + want float64 + }{ + {"identical strings score 1", "cliente", "cliente", 1.0}, + {"no shared trigram scores 0", "abc", "xyz", 0.0}, + {"empty left side scores 0", "", "cliente", 0.0}, + {"empty right side scores 0", "cliente", "", 0.0}, + {"both empty scores 0", "", "", 0.0}, + {"case is ignored", "Cliente", "CLIENTE", 1.0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := infer.TrigramSimilarity(tt.a, tt.b) + if got != tt.want { + t.Errorf("TrigramSimilarity(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want) + } + }) + } +} + +// TestTrigramSimilarityCorpusExamples pins the metric against the real misses +// documented in docs/PGFATHOM.md — abbreviation and reordering that no +// affix/plural match reaches. Bounds, not exact literals: the value is +// computed by the function itself, and the test documents why that range is +// expected rather than asserting a hand-guessed float. +func TestTrigramSimilarityCorpusExamples(t *testing.T) { + tests := []struct { + name string + a, b string + wantAtLeast float64 + }{ + // idkey_operador -> entity "operador", stripped by the pt-br profile, + // is a literal prefix of the target table name: high overlap expected. + {"operador is a prefix of operadorbasecalculo", "operador", "operadorbasecalculo", 0.45}, + // atorevogacao_idkey -> entity "atorevogacao" shares its first three + // letters with the target "ato" and nothing else: overlap is real but + // modest, consistent with a short target name diluting the coefficient. + {"atorevogacao shares a prefix with ato", "atorevogacao", "ato", 0.20}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := infer.TrigramSimilarity(tt.a, tt.b) + if got < tt.wantAtLeast || got > 1.0 { + t.Errorf("TrigramSimilarity(%q, %q) = %v, want >= %v", tt.a, tt.b, got, tt.wantAtLeast) + } + t.Logf("TrigramSimilarity(%q, %q) = %v", tt.a, tt.b, got) + }) + } + + // tptramite_idkey -> entity "tptramite" against the target "tramitetipo": + // abbreviated and reordered, the case this metric exists for. Asserted as + // a range, not a floor: the reordering costs enough shared trigrams that + // this is the borderline case the default cutoff (0.65) is calibrated + // against, not a clean high match. + got := infer.TrigramSimilarity("tptramite", "tramitetipo") + if got <= 0 || got >= 1 { + t.Errorf("TrigramSimilarity(tptramite, tramitetipo) = %v, want strictly between 0 and 1", got) + } + if math.IsNaN(got) { + t.Fatal("TrigramSimilarity must never return NaN") + } + t.Logf("TrigramSimilarity(tptramite, tramitetipo) = %v", got) +} diff --git a/internal/model/candidate.go b/internal/model/candidate.go index 04984d5..8d72cdd 100644 --- a/internal/model/candidate.go +++ b/internal/model/candidate.go @@ -10,6 +10,13 @@ type SignalKind string const ( SigExactName SignalKind = "exact_name" SigNormalizedName SignalKind = "normalized_name" + + // SigNameSimilarity is a lexical match found by character-trigram + // similarity rather than by the naming profile's affix/plural forms. It + // only fires when the profile matched nothing at all, and it ranks below + // SigNormalizedName because string proximity carries no confirmed + // convention behind it. + SigNameSimilarity SignalKind = "name_similarity" ) // Type evidence. diff --git a/internal/model/coverage.go b/internal/model/coverage.go index 148d4fd..cdd86ce 100644 --- a/internal/model/coverage.go +++ b/internal/model/coverage.go @@ -81,6 +81,20 @@ type Coverage struct { // PgStatStatements reports whether the query log was available to mine. PgStatStatements bool `json:"pg_stat_statements"` + + // KeyProbesSkipped lists tables whose missing-key suggestion was not + // probed against the data, and why — too large for the configured + // ceiling, or probing disabled outright. A skip here is not silence: the + // table still carries its missing_primary_key finding, just without a + // probed verdict backing a candidate key. + KeyProbesSkipped []SkippedKeyProbe `json:"key_probes_skipped,omitempty"` +} + +// SkippedKeyProbe records one table whose key candidates were not tested for +// uniqueness against the data, and why. +type SkippedKeyProbe struct { + Table string `json:"table"` + Reason string `json:"reason"` } // Complete reports whether every table in scope was analyzed and every diff --git a/internal/model/evidence.go b/internal/model/evidence.go index 10aa338..be58c6c 100644 --- a/internal/model/evidence.go +++ b/internal/model/evidence.go @@ -1,5 +1,7 @@ package model +import "strings" + // JoinSource says where a join predicate was found. type JoinSource string @@ -37,3 +39,107 @@ func (e JoinEvidence) SignalFor() SignalKind { return SigJoinInView } } + +// OperatorClass groups predicate operators by the index access method they +// call for. The set is closed: an operator the extractor cannot classify +// produces no PredicateEvidence at all, rather than a guessed class. +type OperatorClass string + +const ( + // OpEquality is served by btree, which is never the wrong recommendation. + OpEquality OperatorClass = "eq" + + // OpRange is a comparison served by btree, same as OpEquality. + OpRange OperatorClass = "range" + + // OpLikePrefix is a LIKE/ILIKE anchored at the start of the pattern — + // still a btree case. + OpLikePrefix OperatorClass = "like_prefix" + + // OpLikeInfix is unanchored and wants a trigram index. + OpLikeInfix OperatorClass = "like_infix" + + // OpContainment covers jsonb/array containment and membership (@>, <@, ?, + // ?|, ?&), served by GIN. + OpContainment OperatorClass = "containment" + + // OpFullText is the @@ text-search match operator, served by GIN. + OpFullText OperatorClass = "fulltext" + + // OpVectorDistance is a pgvector distance operator (<->, <=>, <#>), served + // by a nearest-neighbor index method such as HNSW. + OpVectorDistance OperatorClass = "vector_distance" +) + +// PredicateEvidence is one predicate on a resolved column, extracted from SQL +// the database itself stores. Unlike JoinEvidence, which pairs two columns, +// this describes one column's operator, and exists to drive index method +// recommendations rather than relationship inference. +type PredicateEvidence struct { + Column ColumnRef `json:"column"` + Operator OperatorClass `json:"operator"` + Source JoinSource `json:"source"` + + // Object names the view, function, or statement the predicate came from — + // the fact a user can go read to check the evidence. + Object string `json:"object"` +} + +// IndexMethodFor maps a predicate operator and the column's base type to the +// index access method that serves it and, when the method needs one, the +// operator class — given the extensions actually installed. +// +// btree is the default for equality, range, and prefix LIKE: it is never the +// wrong recommendation. A method gated on an extension or a type it cannot +// honestly claim degrades to btree with a note when btree is still a +// reasonable fallback, or to an empty method — meaning no honest +// recommendation exists — when it is not. An empty method must never reach a +// CREATE INDEX statement the server would reject: GIN has no default operator +// class for plain text, and a bare column reference on a non-jsonb, +// non-array, non-tsvector type would fail exactly that way. +func IndexMethodFor(op OperatorClass, baseType string, ext ExtensionSet) (method, opclass, note string) { + switch op { + case OpContainment: + if isContainerType(baseType) { + return "gin", "", "" + } + return "", "", "" + case OpFullText: + if baseType == "tsvector" { + return "gin", "", "" + } + // A GIN index over the raw column would fail: full-text search over a + // plain text column needs an expression index on to_tsvector(...), and + // guessing the text search configuration inside the expression is not + // this layer's call to make. + return "", "", "" + case OpLikeInfix: + if ext.Has("pg_trgm") { + return "gin", "gin_trgm_ops", "" + } + return "btree", "", "infix LIKE would benefit from pg_trgm, which is not installed" + case OpVectorDistance: + if !ext.Has("vector") { + return "", "", "vector distance operator found but pgvector is not installed" + } + if baseType != "vector" { + return "", "", "" + } + return "hnsw", "vector_l2_ops", "defaulted to the L2 operator class; switch to " + + "vector_cosine_ops or vector_ip_ops if the query actually uses <=> or <#>" + default: + return "btree", "", "" + } +} + +// isContainerType reports whether GIN has a default operator class for +// baseType, so a containment predicate can be indexed without an expression. +// Array types carry base_type as pg_type.typname does — an underscore prefix, +// e.g. "_int4" for integer[] — which is what the trailing check catches. +func isContainerType(baseType string) bool { + switch baseType { + case "jsonb", "hstore": + return true + } + return strings.HasPrefix(baseType, "_") +} diff --git a/internal/model/evidence_test.go b/internal/model/evidence_test.go new file mode 100644 index 0000000..5e0ec19 --- /dev/null +++ b/internal/model/evidence_test.go @@ -0,0 +1,75 @@ +package model_test + +import ( + "testing" + + "github.com/lvcas-dotcom/pgfathom/internal/model" +) + +func TestIndexMethodForDefaultsToBtree(t *testing.T) { + for _, op := range []model.OperatorClass{model.OpEquality, model.OpRange, model.OpLikePrefix} { + method, opclass, note := model.IndexMethodFor(op, "int8", model.NewExtensionSet(nil)) + if method != "btree" || opclass != "" || note != "" { + t.Errorf("IndexMethodFor(%v) = %q, %q, %q; want btree, no opclass, no note", op, method, opclass, note) + } + } +} + +func TestIndexMethodForContainmentGatesOnType(t *testing.T) { + tests := []struct { + baseType string + wantMethod string + }{ + {"jsonb", "gin"}, + {"hstore", "gin"}, + {"_int4", "gin"}, // integer[]: base_type carries pg_type.typname's underscore prefix + {"text", ""}, // GIN has no default operator class for plain text + } + + for _, tt := range tests { + method, _, _ := model.IndexMethodFor(model.OpContainment, tt.baseType, model.NewExtensionSet(nil)) + if method != tt.wantMethod { + t.Errorf("IndexMethodFor(containment, %q) method = %q, want %q", tt.baseType, method, tt.wantMethod) + } + } +} + +// TestIndexMethodForFullTextRequiresTsvector pins the rule that a GIN index +// is only recommended for a stored tsvector column: a plain text column +// under @@ needs an expression index this layer must not guess. +func TestIndexMethodForFullTextRequiresTsvector(t *testing.T) { + if method, _, _ := model.IndexMethodFor(model.OpFullText, "tsvector", model.NewExtensionSet(nil)); method != "gin" { + t.Errorf("tsvector column: method = %q, want gin", method) + } + if method, _, _ := model.IndexMethodFor(model.OpFullText, "text", model.NewExtensionSet(nil)); method != "" { + t.Errorf("plain text column: method = %q, want no recommendation", method) + } +} + +func TestIndexMethodForLikeInfixDegradesWithoutPgTrgm(t *testing.T) { + method, opclass, note := model.IndexMethodFor(model.OpLikeInfix, "text", model.NewExtensionSet(nil)) + if method != "btree" || opclass != "" || note == "" { + t.Errorf("without pg_trgm: got %q, %q, %q; want btree fallback with a note", method, opclass, note) + } + + method, opclass, note = model.IndexMethodFor(model.OpLikeInfix, "text", model.NewExtensionSet([]string{"pg_trgm"})) + if method != "gin" || opclass != "gin_trgm_ops" || note != "" { + t.Errorf("with pg_trgm: got %q, %q, %q; want gin/gin_trgm_ops, no note", method, opclass, note) + } +} + +func TestIndexMethodForVectorDistanceRequiresExtensionAndType(t *testing.T) { + if method, _, note := model.IndexMethodFor(model.OpVectorDistance, "vector", model.NewExtensionSet(nil)); method != "" || note == "" { + t.Errorf("without pgvector: got method %q, note %q; want no method and a note", method, note) + } + + vec := model.NewExtensionSet([]string{"vector"}) + if method, _, note := model.IndexMethodFor(model.OpVectorDistance, "int8", vec); method != "" || note != "" { + t.Errorf("pgvector installed but column not vector-typed: got %q, %q; want no honest recommendation", method, note) + } + + method, opclass, note := model.IndexMethodFor(model.OpVectorDistance, "vector", vec) + if method != "hnsw" || opclass != "vector_l2_ops" || note == "" { + t.Errorf("pgvector installed, vector column: got %q, %q, %q; want hnsw/vector_l2_ops with a caveat note", method, opclass, note) + } +} diff --git a/internal/model/extension.go b/internal/model/extension.go new file mode 100644 index 0000000..356c607 --- /dev/null +++ b/internal/model/extension.go @@ -0,0 +1,24 @@ +package model + +import "strings" + +// ExtensionSet is the set of PostgreSQL extensions installed in the target +// database, by name. A read that fails degrades to an empty set — treated +// exactly like "nothing installed" rather than an error — so a recommendation +// gated on an extension never turns absence into a crash. +type ExtensionSet map[string]bool + +// NewExtensionSet builds a set from the extension names read from the +// catalog. +func NewExtensionSet(names []string) ExtensionSet { + out := make(ExtensionSet, len(names)) + for _, n := range names { + out[strings.ToLower(n)] = true + } + return out +} + +// Has reports whether the named extension is installed. +func (e ExtensionSet) Has(name string) bool { + return e[strings.ToLower(name)] +} diff --git a/internal/model/finding.go b/internal/model/finding.go index 08aa3be..6667fd5 100644 --- a/internal/model/finding.go +++ b/internal/model/finding.go @@ -29,6 +29,16 @@ const ( // carrying the same key. Each is a near miss, and near misses are where the // recall that got away is legible. FindingUnsupportedTarget FindingKind = "unsupported_target" + + // FindingMissingPrimaryKey is a table with no primary key: no row + // identity, no logical replication, and a sequential scan behind every + // per-row update or delete. + FindingMissingPrimaryKey FindingKind = "missing_primary_key" + + // FindingUnindexedHotColumn is a column that real code — a view, a + // function, or the query log — repeatedly names in a join or filter + // predicate, with no index leading on it. + FindingUnindexedHotColumn FindingKind = "unindexed_hot_column" ) // Finding is a structural observation that did not require inference. @@ -42,4 +52,76 @@ type Finding struct { Detail string `json:"detail,omitempty"` Metrics map[string]int64 `json:"metrics,omitempty"` + + // Suggestion is the remediation the finding proposes, when it has one + // concrete enough to act on. + Suggestion *Suggestion `json:"suggestion,omitempty"` +} + +// SuggestionKind identifies what a Suggestion proposes. +type SuggestionKind string + +const ( + // SuggestPromoteUnique proposes promoting an existing UNIQUE NOT NULL + // constraint to primary key — a change the catalog already proves safe. + SuggestPromoteUnique SuggestionKind = "promote_unique" + + // SuggestCreatePrimaryKey proposes a new primary key over columns whose + // uniqueness the catalog cannot prove on its own. See KeyProbe. + SuggestCreatePrimaryKey SuggestionKind = "create_primary_key" + + // SuggestCreateIndex proposes a new index over a column real code uses + // repeatedly but that leads no index today. + SuggestCreateIndex SuggestionKind = "create_index" + + // SuggestSyntheticPrimaryKey proposes a brand-new identity column as + // primary key, for a table with no confirmed natural key. Columns carries + // the chosen column name; KeyProbe stays empty because correctness here + // comes from creating the column, not from anything already in the data. + SuggestSyntheticPrimaryKey SuggestionKind = "synthesize_primary_key" +) + +// KeyProbeVerdict is the outcome of confirming candidate key columns against +// the data by counting rows. The set is closed and deliberately asymmetric: +// there is no "not a key" value, because the probe that finds a duplicate +// simply produces no confirmed suggestion at all. +type KeyProbeVerdict string + +const ( + // KeyProbeConfirmed means a full scan found total = distinct and zero + // nulls: the columns are a real key, proven, not guessed. + KeyProbeConfirmed KeyProbeVerdict = "confirmed" + + // KeyProbeUnverified means the probe could not reach a conclusion — + // timeout, table too large, or disabled — and the suggestion stands as an + // unconfirmed hypothesis. + KeyProbeUnverified KeyProbeVerdict = "unverified" +) + +// Suggestion is a remediation an audit finding proposes. Every field names a +// catalog object, a method, or a verdict — never a value read from a table. +type Suggestion struct { + Kind SuggestionKind `json:"kind"` + + // Columns are the catalog column names involved: the candidate key for a + // primary-key suggestion, the leading column for an index suggestion. + Columns []string `json:"columns,omitempty"` + + // IndexMethod is the recommended access method — "btree", "gin", "hnsw" — + // for a SuggestCreateIndex. Empty otherwise. + IndexMethod string `json:"index_method,omitempty"` + + // IndexOpclass is the operator class IndexMethod needs on the column, + // e.g. "gin_trgm_ops" or "vector_l2_ops". Empty when the method's default + // operator class already applies. + IndexOpclass string `json:"index_opclass,omitempty"` + + // Note carries a caveat such as a missing extension. Object names and + // conditions only. + Note string `json:"note,omitempty"` + + // KeyProbe is the verdict of confirming Columns as a real key by counting + // rows. Empty when no probe ran, which includes the promote_unique case: + // the catalog already proved that key, so no probe was needed. + KeyProbe KeyProbeVerdict `json:"key_probe,omitempty"` } diff --git a/internal/model/naming.go b/internal/model/naming.go index 97dfa82..e499345 100644 --- a/internal/model/naming.go +++ b/internal/model/naming.go @@ -1,11 +1,24 @@ package model +// MaxNamingExamples caps how many objects back a NamingEvidence. It exists +// to let a reader verify a detected convention by looking at a few real +// tables, not to enumerate every one that matched — that number is already +// in Occurrences. internal/profile is the only detector today, and it caps +// its accumulation at this same value rather than trimming after the fact. +const MaxNamingExamples = 3 + // NamingEvidence is one naming convention detected in a schema, with what // supports it. type NamingEvidence struct { Affix string `json:"affix"` Occurrences int `json:"occurrences"` Share float64 `json:"share"` + + // Examples names a few of the objects the convention was read from — a + // table, or a schema-qualified column — so a reader can check the claim + // against the schema instead of taking Occurrences on faith. Capped at + // MaxNamingExamples regardless of how many objects actually matched. + Examples []string `json:"examples,omitempty"` } // NamingDetection is what a schema revealed about its own naming convention. @@ -19,13 +32,24 @@ type NamingDetection struct { ColumnPrefixes []NamingEvidence `json:"column_prefixes,omitempty"` TablePrefixes []NamingEvidence `json:"table_prefixes,omitempty"` + // PrimaryKeyNames ranks the literal column name a table with a + // single-column primary key uses, most common first. It is read from + // declared primary keys only, never from a data probe, which is what lets + // audit apply it to a synthetic-column suggestion without reading a row. + PrimaryKeyNames []NamingEvidence `json:"primary_key_names,omitempty"` + // DeclaredKeys is how many declared foreign keys the reference affixes were // read from. Zero means the schema had nothing to say. DeclaredKeys int `json:"declared_keys"` Tables int `json:"tables"` + + // SinglePKTables is how many tables PrimaryKeyNames was tabulated from — + // the population its Share is relative to, not Tables. + SinglePKTables int `json:"single_pk_tables"` } // Empty reports whether nothing was detected. func (d NamingDetection) Empty() bool { - return len(d.ColumnSuffixes) == 0 && len(d.ColumnPrefixes) == 0 && len(d.TablePrefixes) == 0 + return len(d.ColumnSuffixes) == 0 && len(d.ColumnPrefixes) == 0 && + len(d.TablePrefixes) == 0 && len(d.PrimaryKeyNames) == 0 } diff --git a/internal/model/result.go b/internal/model/result.go index 40f070d..d6c97c1 100644 --- a/internal/model/result.go +++ b/internal/model/result.go @@ -5,7 +5,10 @@ import "time" // SchemaVersion is the version of the JSON contract this build emits. The // serialized model is a public API — the CI baseline and third-party tooling // consume it — so any incompatible change requires incrementing this. -const SchemaVersion = "1" +// +// 2 added Finding.Suggestion, additive but signaling that a Finding may now +// carry a remediation proposal. +const SchemaVersion = "2" // Result is a complete analysis run. type Result struct { diff --git a/internal/model/schema.go b/internal/model/schema.go index ed5c475..7953717 100644 --- a/internal/model/schema.go +++ b/internal/model/schema.go @@ -17,7 +17,10 @@ type Table struct { // PrimaryKey lists column names in key order. Empty when there is none. PrimaryKey []string `json:"primary_key,omitempty"` - Uniques [][]string `json:"uniques,omitempty"` + // Uniques holds every UNIQUE constraint declared on the table, name and + // columns together. The name is what lets a promotable one be turned into + // a primary key with ADD CONSTRAINT ... USING INDEX rather than a guess. + Uniques []UniqueConstraint `json:"uniques,omitempty"` // ForeignKeys holds DECLARED constraints only. Inferred relationships live // in Candidate, so no consumer can mistake one for the other. @@ -38,6 +41,38 @@ type Table struct { // Ref returns the schema-qualified name. func (t Table) Ref() string { return t.Schema + "." + t.Name } +// HasPrimaryKey reports whether the table declares a primary key of any shape, +// single-column or composite. +func (t Table) HasPrimaryKey() bool { return len(t.PrimaryKey) > 0 } + +// PromotableUnique returns the first UNIQUE constraint whose columns are all +// NOT NULL — the shape that already proves a primary key without reading a +// row, because the catalog already guarantees uniqueness and non-null. It +// does not mean the constraint's own index can be reused directly: that +// index is already tied to this constraint, and PostgreSQL's USING INDEX +// promotion only accepts one with no constraint attached yet. Keeping the +// name is what lets a caller name the old constraint to drop once a fresh +// index has taken its place. The second result is false when no such unique +// exists. +func (t Table) PromotableUnique() (UniqueConstraint, bool) { + for _, u := range t.Uniques { + if t.allNotNull(u.Columns) { + return u, true + } + } + return UniqueConstraint{}, false +} + +func (t Table) allNotNull(columns []string) bool { + for _, name := range columns { + col, ok := t.Column(name) + if !ok || col.Nullable { + return false + } + } + return true +} + // Column looks up a column by name. The second result is false when absent. func (t Table) Column(name string) (Column, bool) { for _, c := range t.Columns { @@ -100,6 +135,12 @@ type Column struct { Comment string `json:"comment,omitempty"` } +// UniqueConstraint is a UNIQUE constraint as declared in the catalog. +type UniqueConstraint struct { + Name string `json:"name"` + Columns []string `json:"columns"` +} + // ColumnRef points at one column of one table. It is the right unit where the // subject really is a single column: planner statistics are per column, and an // equality mined from SQL relates two of them. diff --git a/internal/profile/detect.go b/internal/profile/detect.go index f972770..941b3e7 100644 --- a/internal/profile/detect.go +++ b/internal/profile/detect.go @@ -21,22 +21,51 @@ const ( minRefAffixCount = 3 minTablePrefixShare = 0.10 minTablePrefixCount = 3 + minPKNameShare = 0.15 + minPKNameCount = 3 ) +// namingAccumulator counts occurrences of a candidate affix or name and keeps +// a few example objects to back it. Examples are capped as they accumulate, +// not trimmed afterward — a schema with thousands of tables never grows one +// past a handful of short strings. +type namingAccumulator struct { + count int + examples []string +} + +func accumulate(m map[string]*namingAccumulator, key, example string) { + a, ok := m[key] + if !ok { + a = &namingAccumulator{} + m[key] = a + } + a.count++ + if len(a.examples) < model.MaxNamingExamples { + a.examples = append(a.examples, example) + } +} + // Detect derives naming conventions from a schema, using only what the catalog // read already produced. It issues no queries and reads no data. func (p *Profile) Detect(schemas []model.Schema) model.NamingDetection { d := model.NamingDetection{Enabled: true} - suffixes, prefixes := map[string]int{}, map[string]int{} - tablePrefixes := map[string]int{} + suffixes, prefixes := map[string]*namingAccumulator{}, map[string]*namingAccumulator{} + tablePrefixes := map[string]*namingAccumulator{} + pkNames := map[string]*namingAccumulator{} for _, s := range schemas { for _, t := range s.Tables { d.Tables++ for _, prefix := range candidateTablePrefixes(t.Name) { - tablePrefixes[prefix]++ + accumulate(tablePrefixes, prefix, t.Name) + } + + if len(t.PrimaryKey) == 1 { + d.SinglePKTables++ + accumulate(pkNames, strings.ToLower(strings.TrimSpace(t.PrimaryKey[0])), t.Name) } for _, fk := range t.ForeignKeys { @@ -49,11 +78,12 @@ func (p *Profile) Detect(schemas []model.Schema) model.NamingDetection { if !ok { continue } + example := t.Name + "." + fk.Columns[0] if suffix != "" { - suffixes[suffix]++ + accumulate(suffixes, suffix, example) } if prefix != "" { - prefixes[prefix]++ + accumulate(prefixes, prefix, example) } } } @@ -62,6 +92,7 @@ func (p *Profile) Detect(schemas []model.Schema) model.NamingDetection { d.ColumnSuffixes = rank(suffixes, d.DeclaredKeys, minRefAffixShare, minRefAffixCount) d.ColumnPrefixes = rank(prefixes, d.DeclaredKeys, minRefAffixShare, minRefAffixCount) d.TablePrefixes = rank(tablePrefixes, d.Tables, minTablePrefixShare, minTablePrefixCount) + d.PrimaryKeyNames = rank(pkNames, d.SinglePKTables, minPKNameShare, minPKNameCount) return d } @@ -119,18 +150,18 @@ func isSeparator(r rune) bool { } // rank keeps the candidates frequent enough to be a convention, strongest first. -func rank(counts map[string]int, population int, minShare float64, minCount int) []model.NamingEvidence { +func rank(counts map[string]*namingAccumulator, population int, minShare float64, minCount int) []model.NamingEvidence { if population <= 0 { return nil } out := make([]model.NamingEvidence, 0, len(counts)) - for affix, n := range counts { - share := float64(n) / float64(population) - if n < minCount || share < minShare { + for affix, a := range counts { + share := float64(a.count) / float64(population) + if a.count < minCount || share < minShare { continue } - out = append(out, model.NamingEvidence{Affix: affix, Occurrences: n, Share: share}) + out = append(out, model.NamingEvidence{Affix: affix, Occurrences: a.count, Share: share, Examples: a.examples}) } sort.Slice(out, func(i, j int) bool { diff --git a/internal/profile/detect_test.go b/internal/profile/detect_test.go index df88cea..7aee0a9 100644 --- a/internal/profile/detect_test.go +++ b/internal/profile/detect_test.go @@ -1,6 +1,7 @@ package profile_test import ( + "fmt" "slices" "testing" @@ -61,6 +62,16 @@ func TestDetectsSuffixFromDeclaredKeys(t *testing.T) { if d.DeclaredKeys != 3 { t.Errorf("DeclaredKeys = %d, want 3", d.DeclaredKeys) } + + suffix := d.ColumnSuffixes[0] + if len(suffix.Examples) == 0 { + t.Fatal("Examples is empty, want the qualified columns the suffix was read from") + } + for _, ex := range suffix.Examples { + if !slices.Contains([]string{"imovel.lote_idkey", "imovel.bairro_idkey", "imovel.logradouro_idkey"}, ex) { + t.Errorf("Examples contains %q, want one of the declared FK columns on imovel", ex) + } + } } func TestDetectsPrefixFromDeclaredKeys(t *testing.T) { @@ -227,6 +238,77 @@ func TestDetectionRecoversTheRealCase(t *testing.T) { } } +// TestDetectsPrimaryKeyNameConvention is what lets audit propose a synthetic +// column name without asking: the schema already names its PK the same way +// in almost every table that has one. +func TestDetectsPrimaryKeyNameConvention(t *testing.T) { + p := mustLoad(t, "pt-br") + + d := p.Detect(schemaOf( + table("lote"), table("bairro"), table("logradouro"), table("operador"), + )) + + if len(d.PrimaryKeyNames) == 0 || d.PrimaryKeyNames[0].Affix != "idkey" { + t.Fatalf("PrimaryKeyNames = %+v, want idkey first", d.PrimaryKeyNames) + } + if d.PrimaryKeyNames[0].Occurrences != 4 || d.PrimaryKeyNames[0].Share != 1 { + t.Errorf("PrimaryKeyNames[0] = %+v, want 4 occurrences at share 1.0", d.PrimaryKeyNames[0]) + } + if d.SinglePKTables != 4 { + t.Errorf("SinglePKTables = %d, want 4", d.SinglePKTables) + } + + want := map[string]bool{"lote": true, "bairro": true, "logradouro": true, "operador": true} + examples := d.PrimaryKeyNames[0].Examples + if len(examples) == 0 { + t.Fatal("Examples is empty, want the tables idkey was read from") + } + for _, ex := range examples { + if !want[ex] { + t.Errorf("Examples contains %q, want one of the four fixture tables", ex) + } + } +} + +// TestNamingExamplesAreCapped proves a convention shared by far more tables +// than the cap still reports every occurrence, but only a handful of +// examples — the whole point of a citation is to be checkable, not to +// reproduce the schema. +func TestNamingExamplesAreCapped(t *testing.T) { + p := mustLoad(t, "pt-br") + + tables := make([]model.Table, 0, 10) + for i := 0; i < 10; i++ { + tables = append(tables, table(fmt.Sprintf("tabela_%d", i))) + } + + d := p.Detect(schemaOf(tables...)) + + if d.PrimaryKeyNames[0].Occurrences != 10 { + t.Errorf("Occurrences = %d, want 10: the cap must not shrink the count", d.PrimaryKeyNames[0].Occurrences) + } + if len(d.PrimaryKeyNames[0].Examples) != model.MaxNamingExamples { + t.Errorf("len(Examples) = %d, want exactly %d", len(d.PrimaryKeyNames[0].Examples), model.MaxNamingExamples) + } +} + +// TestDetectsNoPrimaryKeyNameWithoutAnyKey guards the population-zero path: +// no table with a PK must never mean a divide-by-zero, it must mean nothing +// detected. +func TestDetectsNoPrimaryKeyNameWithoutAnyKey(t *testing.T) { + p := mustLoad(t, "pt-br") + + noPK := model.Table{Schema: "public", Name: "staging_import", Columns: []model.Column{{Name: "raw", BaseType: "text"}}} + d := p.Detect(schemaOf(noPK)) + + if len(d.PrimaryKeyNames) != 0 { + t.Errorf("PrimaryKeyNames = %+v, want none", d.PrimaryKeyNames) + } + if d.SinglePKTables != 0 { + t.Errorf("SinglePKTables = %d, want 0", d.SinglePKTables) + } +} + func TestEmptyReportsNothingDetected(t *testing.T) { if !(model.NamingDetection{}).Empty() { t.Error("an empty detection must report itself as empty") diff --git a/internal/report/discover.go b/internal/report/discover.go index 38e9c4d..50ca103 100644 --- a/internal/report/discover.go +++ b/internal/report/discover.go @@ -329,8 +329,11 @@ func writeDetection(b *strings.Builder, v DiscoverView) { {"table prefix", v.Detection.TablePrefixes}, } { for _, e := range group.items { - writeRow(tw, group.label, e.Affix, - fmt.Sprintf("%d occurrences (%.0f%%)", e.Occurrences, 100*e.Share)) + detail := fmt.Sprintf("%d occurrences (%.0f%%)", e.Occurrences, 100*e.Share) + if len(e.Examples) > 0 { + detail += " — e.g. " + strings.Join(e.Examples, ", ") + } + writeRow(tw, group.label, e.Affix, detail) } } _ = tw.Flush() diff --git a/internal/report/discover_test.go b/internal/report/discover_test.go index 46d1b52..2d1f017 100644 --- a/internal/report/discover_test.go +++ b/internal/report/discover_test.go @@ -178,6 +178,27 @@ func TestDetectionIsReported(t *testing.T) { } } +// TestDetectionCitesExamples proves a detected convention names the objects +// it was read from, not just a count and a percentage — a reader has to be +// able to check the claim against the schema. +func TestDetectionCitesExamples(t *testing.T) { + v := discoverView(nil, nil, false) + v.Detection = model.NamingDetection{ + Enabled: true, + ColumnSuffixes: []model.NamingEvidence{ + {Affix: "_idkey", Occurrences: 102, Share: 0.22, Examples: []string{"imovel.lote_idkey", "pedido.cliente_idkey"}}, + }, + DeclaredKeys: 470, + Tables: 338, + } + + out := renderDiscover(t, v) + + if !strings.Contains(out, "imovel.lote_idkey") || !strings.Contains(out, "pedido.cliente_idkey") { + t.Errorf("the examples backing the convention must be named:\n%s", out) + } +} + func TestDetectionOffIsStated(t *testing.T) { out := renderDiscover(t, discoverView(nil, nil, false)) diff --git a/internal/report/json_test.go b/internal/report/json_test.go index 2bcd3d8..1df14ab 100644 --- a/internal/report/json_test.go +++ b/internal/report/json_test.go @@ -53,15 +53,23 @@ func contractResult() *model.Result { StatsPrefilter: true, CandidatesStatsChecked: 9, CandidatesStatsRejected: 4, CandidatesWithoutStats: 1, StatsResetAt: &resetAt, PgStatStatements: true, + KeyProbesSkipped: []model.SkippedKeyProbe{ + {Table: "public.big_table", Reason: "exceeds --probe-keys-max-rows"}, + }, }) r.Duration = goldenDuration r.ServerVersion = goldenServer r.Naming = model.NamingDetection{ - Enabled: true, - ColumnSuffixes: []model.NamingEvidence{{Affix: "_idkey", Occurrences: 102, Share: 0.22}}, + Enabled: true, + ColumnSuffixes: []model.NamingEvidence{ + {Affix: "_idkey", Occurrences: 102, Share: 0.22, Examples: []string{"imovel.lote_idkey"}}, + }, ColumnPrefixes: []model.NamingEvidence{{Affix: "cod_", Occurrences: 41, Share: 0.09}}, TablePrefixes: []model.NamingEvidence{{Affix: "tpl_", Occurrences: 88, Share: 0.26}}, - DeclaredKeys: 470, Tables: 338, + PrimaryKeyNames: []model.NamingEvidence{ + {Affix: "idkey", Occurrences: 300, Share: 0.89, Examples: []string{"cliente", "pedido"}}, + }, + DeclaredKeys: 470, Tables: 338, SinglePKTables: 338, } r.Schemas = []model.Schema{{ @@ -73,7 +81,7 @@ func contractResult() *model.Result { Nullable: true, Default: "NULL", Position: 2, Comment: "referencia ao cliente", }}, PrimaryKey: []string{"id"}, - Uniques: [][]string{{"numero"}}, + Uniques: []model.UniqueConstraint{{Name: "pedido_numero_key", Columns: []string{"numero"}}}, ForeignKeys: []model.ForeignKey{{ Name: "pedido_cliente_fkey", Columns: []string{"cliente_id"}, RefSchema: "public", RefTable: "cliente", RefColumns: []string{"id"}, @@ -121,10 +129,20 @@ func contractResult() *model.Result { } r.Discarded = []model.Candidate{verdictCandidate("log", "status_id", "status", model.VerdictRejected, nil, "low containment: the name match is a coincidence")} - r.Findings = []model.Finding{{ - Kind: model.FindingNotValidConstraint, Object: "public.pedido", - Detail: "never verified", Metrics: map[string]int64{"rows": 1_284_000}, - }} + r.Findings = []model.Finding{ + { + Kind: model.FindingNotValidConstraint, Object: "public.pedido", + Detail: "never verified", Metrics: map[string]int64{"rows": 1_284_000}, + }, + { + Kind: model.FindingMissingPrimaryKey, Object: "public.cadastro", + Detail: "no primary key", + Suggestion: &model.Suggestion{ + Kind: model.SuggestCreatePrimaryKey, Columns: []string{"cpf"}, + IndexMethod: "btree", Note: "candidate confirmed by a full scan", KeyProbe: model.KeyProbeConfirmed, + }, + }, + } return r } diff --git a/internal/report/sql.go b/internal/report/sql.go index 647a37e..d9c3f71 100644 --- a/internal/report/sql.go +++ b/internal/report/sql.go @@ -21,9 +21,11 @@ import ( // rather than accumulating. Comparing two runs is the point; a directory that // grows a timestamped file per execution makes that harder, not easier. const ( - FileConfirmed = "confirmed.sql" - FileBroken = "broken.sql" - FileNotValid = "not_valid.sql" + FileConfirmed = "confirmed.sql" + FileBroken = "broken.sql" + FileNotValid = "not_valid.sql" + FileSuggestedKeys = "suggested_keys.sql" + FileSuggestedIndexes = "suggested_indexes.sql" ) // maxIdentifierBytes is NAMEDATALEN-1. The server truncates past this without @@ -54,13 +56,19 @@ func DiscoverArtifacts(r *model.Result) []Artifact { } // AuditArtifacts renders the SQL for the structural audit: the validation of -// every constraint the catalog carries as NOT VALID. +// every constraint the catalog carries as NOT VALID, the primary keys the +// catalog or a full-scan probe supports, and the indexes real code repeatedly +// asks for and does not have. func AuditArtifacts(r *model.Result) []Artifact { h := newHeader(r) pending := notValidKeys(r.Schemas) + keysContent, keysCount := suggestedKeysFile(h, r.Schemas, r.Findings) + indexesContent, indexesCount := suggestedIndexesFile(h, r.Schemas, r.Findings) return []Artifact{ {Name: FileNotValid, Count: len(pending), Content: notValidFile(h, pending)}, + {Name: FileSuggestedKeys, Count: keysCount, Content: keysContent}, + {Name: FileSuggestedIndexes, Count: indexesCount, Content: indexesContent}, } } @@ -362,6 +370,253 @@ WHERE %s; `, qualify(child), antiJoin(child, parent)) } +// suggestedKeysFile renders the primary keys the catalog or a full-scan probe +// supports. Only a confirmed suggestion produces DDL: an unconfirmed one has +// no columns to act on, the same rule the terminal renderer follows. +func suggestedKeysFile(h header, schemas []model.Schema, findings []model.Finding) (string, int) { + var b strings.Builder + h.render(&b, "primary keys the catalog or a full-scan probe supports") + + var written int + for _, f := range findings { + if f.Kind != model.FindingMissingPrimaryKey || f.Suggestion == nil { + continue + } + + table, ok := tableByRef(schemas, f.Object) + if !ok { + continue + } + + s := f.Suggestion + switch { + case s.Kind == model.SuggestPromoteUnique: + u, ok := table.PromotableUnique() + if !ok { + continue + } + writePromoteUnique(&b, table, u) + written++ + + case s.Kind == model.SuggestCreatePrimaryKey && s.KeyProbe == model.KeyProbeConfirmed && len(s.Columns) > 0: + writeConfirmedPrimaryKey(&b, table, s.Columns) + written++ + + case s.Kind == model.SuggestSyntheticPrimaryKey && len(s.Columns) > 0: + writeSyntheticPrimaryKey(&b, table, s.Columns[0], s.Note) + written++ + } + } + + if written == 0 { + b.WriteString("-- No missing-key suggestion in this run reached a safe DDL: either every\n") + b.WriteString("-- table already has one, or no candidate was confirmed by a full scan.\n") + } + + return b.String(), written +} + +// writePromoteUnique emits the path for a key an existing UNIQUE constraint +// already proves: every column is NOT NULL and the constraint already +// guarantees uniqueness, so no data probe is needed — only the promotion. +// +// The constraint's own index cannot be reused directly: PostgreSQL's +// USING INDEX promotion only accepts an index with no constraint attached +// yet, and this one is already owned by the UNIQUE constraint being +// promoted. Building a fresh index CONCURRENTLY, promoting that one, then +// dropping the old constraint is the same three-step discipline +// writeConfirmedPrimaryKey and writeSyntheticPrimaryKey already use for +// CREATE INDEX CONCURRENTLY in this file — it still scans the table to build +// the new index, so this is lock-light, not free. +func writePromoteUnique(b *strings.Builder, t model.Table, u model.UniqueConstraint) { + idx := truncateIdent("ux_" + t.Name + "_" + strings.Join(u.Columns, "_")) + + fmt.Fprintf(b, "-- %s — promote UNIQUE %s (%s) to primary key\n", + t.Ref(), ident(u.Name), strings.Join(quotedIdents(u.Columns), ", ")) + b.WriteString("-- Every column is NOT NULL and the constraint already guarantees\n") + b.WriteString("-- uniqueness, so no data probe is needed. Its own index cannot be reused\n") + b.WriteString("-- directly — USING INDEX rejects one already tied to a constraint — so a\n") + b.WriteString("-- fresh index is built CONCURRENTLY first, promoted, and the old\n") + b.WriteString("-- constraint is dropped last.\n") + if idx.truncated { + fmt.Fprintf(b, "-- Name shortened to fit %d bytes; in full it would be %s.\n", maxIdentifierBytes, idx.full) + } + fmt.Fprintf(b, "-- CREATE UNIQUE INDEX CONCURRENTLY %s ON %s (%s);\n", + ident(idx.value), identTable(t.Schema, t.Name), strings.Join(quotedIdents(u.Columns), ", ")) + fmt.Fprintf(b, "-- ALTER TABLE %s ADD PRIMARY KEY USING INDEX %s;\n", + identTable(t.Schema, t.Name), ident(idx.value)) + fmt.Fprintf(b, "-- ALTER TABLE %s DROP CONSTRAINT %s;\n\n", + identTable(t.Schema, t.Name), ident(u.Name)) +} + +// writeConfirmedPrimaryKey emits the two-step path for a key the catalog +// could not prove on its own: build the unique index CONCURRENTLY, then +// promote it. Both steps are commented, the same discipline +// writeIndexSuggestion uses for CREATE INDEX CONCURRENTLY — it cannot run +// inside a transaction block, and a failure leaves an INVALID index behind +// that has to be dropped by hand. +func writeConfirmedPrimaryKey(b *strings.Builder, t model.Table, columns []string) { + idx := truncateIdent("ux_" + t.Name + "_" + strings.Join(columns, "_")) + + fmt.Fprintf(b, "-- %s — confirmed by a full scan: every row has a non-null value in\n", t.Ref()) + fmt.Fprintf(b, "-- (%s) and no two rows share one.\n", strings.Join(columns, ", ")) + b.WriteString("--\n") + b.WriteString("-- ADD PRIMARY KEY directly takes an ACCESS EXCLUSIVE lock and rebuilds the\n") + b.WriteString("-- index from scratch. Building it CONCURRENTLY first, then promoting it,\n") + b.WriteString("-- avoids that — the same two-step every index suggestion in this tool uses.\n") + if idx.truncated { + fmt.Fprintf(b, "-- Name shortened to fit %d bytes; in full it would be %s.\n", maxIdentifierBytes, idx.full) + } + fmt.Fprintf(b, "-- CREATE UNIQUE INDEX CONCURRENTLY %s ON %s (%s);\n", + ident(idx.value), identTable(t.Schema, t.Name), strings.Join(quotedIdents(columns), ", ")) + fmt.Fprintf(b, "-- ALTER TABLE %s ADD PRIMARY KEY USING INDEX %s;\n\n", + identTable(t.Schema, t.Name), ident(idx.value)) +} + +// writeSyntheticPrimaryKey emits the path for a table with no natural key at +// all: create the identity column, then promote it the same two-step way +// writeConfirmedPrimaryKey does. The rewrite ADD COLUMN triggers to populate +// the sequence for every existing row is unavoidable — the two-step here +// only saves the second lock, the one ADD PRIMARY KEY would take on top of it. +func writeSyntheticPrimaryKey(b *strings.Builder, t model.Table, column, note string) { + idx := truncateIdent("ux_" + t.Name + "_" + column) + + fmt.Fprintf(b, "-- %s — no natural key confirmed; create a synthetic identity column.\n", t.Ref()) + if note != "" { + fmt.Fprintf(b, "-- %s\n", note) + } + b.WriteString("--\n") + b.WriteString("-- Adding an identity column to a populated table already rewrites it, to\n") + b.WriteString("-- populate the sequence for every existing row — that cost cannot be\n") + b.WriteString("-- avoided. Building the unique index CONCURRENTLY before promoting it only\n") + b.WriteString("-- avoids a second, separate ACCESS EXCLUSIVE lock on top of that rewrite.\n") + if idx.truncated { + fmt.Fprintf(b, "-- Name shortened to fit %d bytes; in full it would be %s.\n", maxIdentifierBytes, idx.full) + } + fmt.Fprintf(b, "-- ALTER TABLE %s ADD COLUMN %s bigint GENERATED ALWAYS AS IDENTITY;\n", + identTable(t.Schema, t.Name), ident(column)) + fmt.Fprintf(b, "-- CREATE UNIQUE INDEX CONCURRENTLY %s ON %s (%s);\n", + ident(idx.value), identTable(t.Schema, t.Name), strings.Join(quotedIdents([]string{column}), ", ")) + fmt.Fprintf(b, "-- ALTER TABLE %s ADD PRIMARY KEY USING INDEX %s;\n\n", + identTable(t.Schema, t.Name), ident(idx.value)) +} + +// suggestedIndexesFile renders the indexes real code repeatedly asks for and +// does not have. +func suggestedIndexesFile(h header, schemas []model.Schema, findings []model.Finding) (string, int) { + var b strings.Builder + h.render(&b, "indexes real code repeatedly asks for and does not have") + + var written int + for _, f := range findings { + if f.Kind != model.FindingUnindexedHotColumn || f.Suggestion == nil || len(f.Suggestion.Columns) == 0 { + continue + } + if writeIndexRecommendation(&b, schemas, f) { + written++ + } + } + + if written == 0 { + b.WriteString("-- No hot, unindexed column found in this run.\n") + } + + return b.String(), written +} + +// writeIndexRecommendation resolves the owning table from the finding's +// object — schema.table.column — and emits a commented CREATE INDEX +// CONCURRENTLY, the same discipline every CONCURRENTLY statement in this +// package follows: it cannot run inside a transaction block, and a failure +// leaves an INVALID index behind that has to be dropped by hand. +func writeIndexRecommendation(b *strings.Builder, schemas []model.Schema, f model.Finding) bool { + cut := strings.LastIndex(f.Object, ".") + if cut < 0 { + return false + } + table, ok := tableByRef(schemas, f.Object[:cut]) + if !ok { + return false + } + + s := f.Suggestion + method := s.IndexMethod + if method == "" { + method = "btree" + } + columns := columnListWithOpclass(s.Columns, s.IndexOpclass) + idx := truncateIdent("ix_" + table.Name + "_" + strings.Join(s.Columns, "_")) + + fmt.Fprintf(b, "-- %s — named repeatedly in real join or filter predicates, no index leads it\n", f.Object) + if s.Note != "" { + fmt.Fprintf(b, "-- %s\n", s.Note) + } + if ext := requiredExtension(method, s.IndexOpclass); ext != "" { + fmt.Fprintf(b, "-- CREATE EXTENSION IF NOT EXISTS %s;\n", ext) + } + b.WriteString("--\n") + b.WriteString("-- CONCURRENTLY does NOT run inside a transaction block: this fails under\n") + b.WriteString("-- psql --single-transaction, and a failure leaves an INVALID index behind\n") + b.WriteString("-- that has to be dropped by hand. Run it on its own.\n") + if idx.truncated { + fmt.Fprintf(b, "-- Name shortened to fit %d bytes; in full it would be %s.\n", maxIdentifierBytes, idx.full) + } + fmt.Fprintf(b, "-- CREATE INDEX CONCURRENTLY %s ON %s USING %s (%s);\n\n", + ident(idx.value), identTable(table.Schema, table.Name), method, columns) + + return true +} + +// requiredExtension names the extension an access method or operator class +// depends on, so the artifact can remind a reader to install it — even though +// this method is only ever recommended when the extension is already present +// on the analyzed server. +func requiredExtension(method, opclass string) string { + switch { + case opclass == "gin_trgm_ops": + return "pg_trgm" + case method == "hnsw": + return "vector" + default: + return "" + } +} + +// tableByRef looks up a table by its schema-qualified reference. +func tableByRef(schemas []model.Schema, ref string) (model.Table, bool) { + for _, s := range schemas { + for _, t := range s.Tables { + if t.Ref() == ref { + return t, true + } + } + } + return model.Table{}, false +} + +// quotedIdents sanitizes a list of column names with no operator class. +func quotedIdents(names []string) []string { + out := make([]string, len(names)) + for i, n := range names { + out[i] = ident(n) + } + return out +} + +// columnListWithOpclass renders a column list for a CREATE INDEX column +// clause, appending the operator class to every column when one is needed. +func columnListWithOpclass(columns []string, opclass string) string { + parts := make([]string, len(columns)) + for i, c := range columns { + q := ident(c) + if opclass != "" { + q += " " + opclass + } + parts[i] = q + } + return strings.Join(parts, ", ") +} + // emptyNote states what an empty category means. In sampled mode the count is // not the story: the mode is, because it could not have confirmed anything. func emptyNote(h header, r *model.Result) string { diff --git a/internal/report/sql_test.go b/internal/report/sql_test.go index 36eec8d..3822a28 100644 --- a/internal/report/sql_test.go +++ b/internal/report/sql_test.go @@ -223,6 +223,199 @@ func TestValidateIsSeparateAndCommented(t *testing.T) { } } +// missingKeyResult carries a table with a promotable unique and a table +// confirmed by a full-scan probe, the two shapes suggested_keys.sql acts on. +func missingKeyResult() *model.Result { + r := model.NewResult(goldenVersion, "", goldenTime(), model.Coverage{TablesTotal: 2, TablesAnalyzed: 2}) + r.ServerVersion = goldenServer + r.Schemas = []model.Schema{{ + Name: "public", + Tables: []model.Table{ + { + Schema: "public", Name: "cadastro", + Columns: []model.Column{{Name: "cpf", Nullable: false}}, + Uniques: []model.UniqueConstraint{{Name: "cadastro_cpf_key", Columns: []string{"cpf"}}}, + }, + { + Schema: "public", Name: "item_pedido", + Columns: []model.Column{{Name: "pedido_id", Nullable: false}, {Name: "sequencia", Nullable: false}}, + }, + }, + }} + r.Findings = []model.Finding{ + { + Kind: model.FindingMissingPrimaryKey, Object: "public.cadastro", + Suggestion: &model.Suggestion{Kind: model.SuggestPromoteUnique, Columns: []string{"cpf"}}, + }, + { + Kind: model.FindingMissingPrimaryKey, Object: "public.item_pedido", + Suggestion: &model.Suggestion{ + Kind: model.SuggestCreatePrimaryKey, Columns: []string{"pedido_id", "sequencia"}, + KeyProbe: model.KeyProbeConfirmed, + }, + }, + } + return r +} + +// TestSuggestedKeysPromotesExistingUniqueViaThreeStepCommented pins the fix: +// the constraint's own index cannot be reused by USING INDEX (it is already +// tied to that constraint), so promotion builds a fresh index instead, and +// the whole sequence stays commented like every other CONCURRENTLY path in +// this file — it still scans the table, so it is lock-light, not free, and +// not meant to run unreviewed. +func TestSuggestedKeysPromotesExistingUniqueViaThreeStepCommented(t *testing.T) { + content := artifactByName(t, report.AuditArtifacts(missingKeyResult()), report.FileSuggestedKeys).Content + + want := []string{ + `CREATE UNIQUE INDEX CONCURRENTLY "ux_cadastro_cpf" ON "public"."cadastro" ("cpf");`, + `ADD PRIMARY KEY USING INDEX "ux_cadastro_cpf";`, + `DROP CONSTRAINT "cadastro_cpf_key";`, + } + for _, w := range want { + if !strings.Contains(content, w) { + t.Errorf("expected the three-step promotion to contain %q:\n%s", w, content) + } + } + + for _, line := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(line) + touches := strings.Contains(line, "ux_cadastro_cpf") || strings.Contains(line, `DROP CONSTRAINT "cadastro_cpf_key"`) + if touches && !strings.HasPrefix(trimmed, "--") { + t.Errorf("promoting an existing unique still scans to build the new index, must stay commented: %q", line) + } + } +} + +func TestSuggestedKeysConfirmedCompositeUsesTwoStepCommented(t *testing.T) { + content := artifactByName(t, report.AuditArtifacts(missingKeyResult()), report.FileSuggestedKeys).Content + + if !strings.Contains(content, `CREATE UNIQUE INDEX CONCURRENTLY`) { + t.Errorf("a key with no existing unique must build one first:\n%s", content) + } + if !strings.Contains(content, `"pedido_id", "sequencia"`) { + t.Errorf("both composite columns must be named:\n%s", content) + } + for _, line := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(line) + if (strings.Contains(line, "CREATE UNIQUE INDEX CONCURRENTLY") || + strings.Contains(line, "ADD PRIMARY KEY USING INDEX")) && !strings.HasPrefix(trimmed, "--") { + t.Errorf("CONCURRENTLY build-then-promote must stay commented: %q", line) + } + } +} + +// TestUnverifiedKeySuggestionProducesNoDDL pins the rule that an unconfirmed +// candidate key never turns into DDL: the file states that instead of +// guessing at columns a probe could not confirm. +func TestUnverifiedKeySuggestionProducesNoDDL(t *testing.T) { + r := model.NewResult(goldenVersion, "", goldenTime(), model.Coverage{TablesTotal: 1, TablesAnalyzed: 1}) + r.Schemas = []model.Schema{{Name: "public", Tables: []model.Table{{Schema: "public", Name: "log_evento"}}}} + r.Findings = []model.Finding{{ + Kind: model.FindingMissingPrimaryKey, Object: "public.log_evento", + Suggestion: &model.Suggestion{Kind: model.SuggestCreatePrimaryKey, KeyProbe: model.KeyProbeUnverified}, + }} + + content := artifactByName(t, report.AuditArtifacts(r), report.FileSuggestedKeys).Content + + if strings.Contains(content, "CREATE") || strings.Contains(content, "ALTER TABLE") { + t.Errorf("an unverified candidate must not produce DDL:\n%s", content) + } + if !strings.Contains(content, "no candidate was confirmed") { + t.Errorf("the file must say why nothing was generated:\n%s", content) + } +} + +// TestSuggestedKeysSyntheticColumnUsesTwoStepCommented pins the same +// commented, two-step discipline as the confirmed-composite case, plus the +// rewrite caveat that is specific to adding a brand-new identity column. +func TestSuggestedKeysSyntheticColumnUsesTwoStepCommented(t *testing.T) { + r := model.NewResult(goldenVersion, "", goldenTime(), model.Coverage{TablesTotal: 1, TablesAnalyzed: 1}) + r.Schemas = []model.Schema{{Name: "public", Tables: []model.Table{{Schema: "public", Name: "log_evento"}}}} + r.Findings = []model.Finding{{ + Kind: model.FindingMissingPrimaryKey, Object: "public.log_evento", + Suggestion: &model.Suggestion{ + Kind: model.SuggestSyntheticPrimaryKey, Columns: []string{"idkey"}, + Note: "user-provided name", + }, + }} + + content := artifactByName(t, report.AuditArtifacts(r), report.FileSuggestedKeys).Content + + if !strings.Contains(content, `ADD COLUMN "idkey" bigint GENERATED ALWAYS AS IDENTITY`) { + t.Errorf("the file must declare the new identity column:\n%s", content) + } + if !strings.Contains(content, "already rewrites it") { + t.Errorf("the file must note the unavoidable rewrite cost:\n%s", content) + } + if !strings.Contains(content, "user-provided name") { + t.Errorf("the file must carry the suggestion's provenance note:\n%s", content) + } + for _, line := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(line) + if (strings.Contains(line, "ADD COLUMN") || strings.Contains(line, "CREATE UNIQUE INDEX CONCURRENTLY") || + strings.Contains(line, "ADD PRIMARY KEY USING INDEX")) && !strings.HasPrefix(trimmed, "--") { + t.Errorf("a synthetic column is never executed by the tool, all three steps must stay commented: %q", line) + } + } +} + +// hotColumnResult carries one btree-worthy join column and one containment +// column needing GIN with a trigram-adjacent operator class, to exercise +// suggested_indexes.sql. +func hotColumnResult() *model.Result { + r := model.NewResult(goldenVersion, "", goldenTime(), model.Coverage{TablesTotal: 2, TablesAnalyzed: 2}) + r.Schemas = []model.Schema{{ + Name: "public", + Tables: []model.Table{ + {Schema: "public", Name: "pedido"}, + {Schema: "public", Name: "evento"}, + }, + }} + r.Findings = []model.Finding{ + { + Kind: model.FindingUnindexedHotColumn, Object: "public.pedido.cliente_id", + Suggestion: &model.Suggestion{Kind: model.SuggestCreateIndex, Columns: []string{"cliente_id"}, IndexMethod: "btree"}, + }, + { + Kind: model.FindingUnindexedHotColumn, Object: "public.evento.nome", + Suggestion: &model.Suggestion{ + Kind: model.SuggestCreateIndex, Columns: []string{"nome"}, + IndexMethod: "gin", IndexOpclass: "gin_trgm_ops", + }, + }, + } + return r +} + +func TestSuggestedIndexesUseConcurrentlyAndStayCommented(t *testing.T) { + content := artifactByName(t, report.AuditArtifacts(hotColumnResult()), report.FileSuggestedIndexes).Content + + for _, line := range strings.Split(content, "\n") { + if strings.Contains(line, "CREATE INDEX") && !strings.HasPrefix(strings.TrimSpace(line), "--") { + t.Errorf("CREATE INDEX CONCURRENTLY must stay commented, the same discipline every "+ + "CONCURRENTLY statement in this package follows: %q", line) + } + } + if !strings.Contains(content, "CREATE INDEX CONCURRENTLY") { + t.Errorf("expected a CONCURRENTLY suggestion:\n%s", content) + } +} + +func TestSuggestedIndexesCarryOperatorClass(t *testing.T) { + content := artifactByName(t, report.AuditArtifacts(hotColumnResult()), report.FileSuggestedIndexes).Content + + if !strings.Contains(content, `USING gin ("nome" gin_trgm_ops)`) { + t.Errorf("the trigram operator class must be attached to the column:\n%s", content) + } + if !strings.Contains(content, "CREATE EXTENSION IF NOT EXISTS pg_trgm") { + t.Errorf("a gin_trgm_ops recommendation must remind the reader what it depends on:\n%s", content) + } + if !strings.Contains(content, `USING btree ("cliente_id")`) { + t.Errorf("a plain btree recommendation must not carry an operator class:\n%s", content) + } +} + // TestGenerationIsDeterministic is what makes golden files and run-to-run // comparison possible at all. func TestGenerationIsDeterministic(t *testing.T) { diff --git a/internal/report/terminal.go b/internal/report/terminal.go index eedcf68..1eaa3f1 100644 --- a/internal/report/terminal.go +++ b/internal/report/terminal.go @@ -21,6 +21,8 @@ var findingTitles = map[model.FindingKind]string{ model.FindingNotValidConstraint: "NOT VALID — declared, never verified against existing rows", model.FindingFKWithoutIndex: "UNINDEXED — foreign key with no index on the child side", model.FindingOrphanReference: "DANGLING — reference to a table that no longer exists", + model.FindingMissingPrimaryKey: "NO PRIMARY KEY — no row identity, sequential scans on every per-row write", + model.FindingUnindexedHotColumn: "HOT COLUMN — named repeatedly in real predicates, no index leading it", } // Terminal writes the audit result as a grouped table. @@ -98,13 +100,55 @@ func writeFindings(b *strings.Builder, e Emphasis, findings []model.Finding) { tw := tabwriter.NewWriter(b, 0, 0, 2, ' ', 0) for _, f := range group { - writeRow(tw, f.Object, formatMetrics(f.Metrics)) + writeRow(tw, f.Object, formatMetrics(f.Metrics), formatSuggestion(f.Suggestion)) } _ = tw.Flush() b.WriteString("\n") } } +// formatSuggestion renders a finding's remediation, when it has one, as a +// single cell: object names, a method, a probe verdict — never a value read +// from a table. +func formatSuggestion(s *model.Suggestion) string { + if s == nil { + return "" + } + + var parts []string + switch s.Kind { + case model.SuggestPromoteUnique: + parts = append(parts, "promote UNIQUE("+strings.Join(s.Columns, ", ")+") to primary key") + case model.SuggestCreatePrimaryKey: + if len(s.Columns) > 0 { + parts = append(parts, "candidate key ("+strings.Join(s.Columns, ", ")+")") + } else { + parts = append(parts, "candidate key not yet probed") + } + case model.SuggestSyntheticPrimaryKey: + parts = append(parts, "create synthetic column "+strings.Join(s.Columns, ", ")+" as primary key") + case model.SuggestCreateIndex: + method := s.IndexMethod + if method == "" { + method = "btree" + } + column := strings.Join(s.Columns, ", ") + if s.IndexOpclass != "" { + column += " " + s.IndexOpclass + } + parts = append(parts, fmt.Sprintf("create index using %s (%s)", method, column)) + } + + if s.KeyProbe != "" { + parts = append(parts, string(s.KeyProbe)) + } + if s.Note != "" { + parts = append(parts, s.Note) + } + + return strings.Join(parts, "; ") +} + func formatMetrics(m map[string]int64) string { if len(m) == 0 { return "" @@ -176,6 +220,14 @@ func writeCoverage(b *strings.Builder, e Emphasis, c model.Coverage) { "report covers that fraction, not the schema", 100*c.AnalyzedShare()))) } + if len(c.KeyProbesSkipped) > 0 { + names := make([]string, len(c.KeyProbesSkipped)) + for i, s := range c.KeyProbesSkipped { + names[i] = s.Table + } + writeSkipped(b, "missing-key candidates not probed against data", names) + } + // The prefilter line distinguishes "I looked and dropped nothing" from "I // did not look". It only appears when there was an inference to filter, so // audit output stays free of it. diff --git a/internal/report/terminal_test.go b/internal/report/terminal_test.go index 57ffce7..3f2e183 100644 --- a/internal/report/terminal_test.go +++ b/internal/report/terminal_test.go @@ -137,6 +137,111 @@ func TestFindingsAreGroupedAndCounted(t *testing.T) { } } +func TestMissingPrimaryKeySuggestionIsRendered(t *testing.T) { + out := render(t, result( + model.Coverage{TablesTotal: 1, TablesAnalyzed: 1}, + model.Finding{ + Kind: model.FindingMissingPrimaryKey, + Object: "public.cadastro", + Suggestion: &model.Suggestion{ + Kind: model.SuggestPromoteUnique, + Columns: []string{"cpf"}, + }, + }, + )) + + if !strings.Contains(out, "NO PRIMARY KEY") { + t.Errorf("missing-key group should be titled:\n%s", out) + } + if !strings.Contains(out, "promote UNIQUE(cpf)") { + t.Errorf("output should name the promotable unique:\n%s", out) + } +} + +func TestSyntheticPrimaryKeySuggestionIsRendered(t *testing.T) { + out := render(t, result( + model.Coverage{TablesTotal: 1, TablesAnalyzed: 1}, + model.Finding{ + Kind: model.FindingMissingPrimaryKey, + Object: "public.log_evento", + Suggestion: &model.Suggestion{ + Kind: model.SuggestSyntheticPrimaryKey, + Columns: []string{"idkey"}, + Note: "schema convention: \"idkey\" names the primary key in 300 of 338 single-column-PK tables (89%)", + }, + }, + )) + + if !strings.Contains(out, "NO PRIMARY KEY") { + t.Errorf("missing-key group should be titled:\n%s", out) + } + if !strings.Contains(out, "create synthetic column idkey as primary key") { + t.Errorf("output should name the synthetic column:\n%s", out) + } + if !strings.Contains(out, "schema convention") { + t.Errorf("output should carry the provenance note:\n%s", out) + } +} + +func TestHotColumnSuggestionShowsIndexMethodAndProbeVerdict(t *testing.T) { + out := render(t, result( + model.Coverage{TablesTotal: 1, TablesAnalyzed: 1}, + model.Finding{ + Kind: model.FindingUnindexedHotColumn, + Object: "public.evento.dados", + Suggestion: &model.Suggestion{ + Kind: model.SuggestCreateIndex, + Columns: []string{"dados"}, + IndexMethod: "gin", + }, + }, + )) + + if !strings.Contains(out, "HOT COLUMN") { + t.Errorf("hot-column group should be titled:\n%s", out) + } + if !strings.Contains(out, "create index using gin (dados)") { + t.Errorf("output should name the recommended index method:\n%s", out) + } +} + +// TestUnverifiedKeyProbeNeverShowsColumns pins the rule that an unconfirmed +// candidate key is never presented with named columns: a reader cannot tell +// "proven not unique" from "timed out" from the columns alone. +func TestUnverifiedKeyProbeNeverShowsColumns(t *testing.T) { + out := render(t, result( + model.Coverage{TablesTotal: 1, TablesAnalyzed: 1}, + model.Finding{ + Kind: model.FindingMissingPrimaryKey, + Object: "public.log_evento", + Suggestion: &model.Suggestion{ + Kind: model.SuggestCreatePrimaryKey, + KeyProbe: model.KeyProbeUnverified, + Note: "tried 2 candidate key(s); none confirmed unique", + }, + }, + )) + + if !strings.Contains(out, "candidate key not yet probed") { + t.Errorf("an unverified suggestion must not imply named columns:\n%s", out) + } + if !strings.Contains(out, "unverified") { + t.Errorf("output should carry the probe verdict:\n%s", out) + } +} + +func TestKeyProbesSkippedAppearInCoverage(t *testing.T) { + out := render(t, result(model.Coverage{ + TablesTotal: 1, + TablesAnalyzed: 1, + KeyProbesSkipped: []model.SkippedKeyProbe{{Table: "public.big_table", Reason: "exceeds --probe-keys-max-rows"}}, + })) + + if !strings.Contains(out, "public.big_table") || !strings.Contains(out, "not probed against data") { + t.Errorf("a skipped key probe must be visible in coverage, not silent:\n%s", out) + } +} + func TestUnknownStatsResetIsFlagged(t *testing.T) { out := render(t, result(model.Coverage{TablesTotal: 1, TablesAnalyzed: 1})) diff --git a/internal/report/testdata/json_contract.golden b/internal/report/testdata/json_contract.golden index dae7660..98693f2 100644 --- a/internal/report/testdata/json_contract.golden +++ b/internal/report/testdata/json_contract.golden @@ -31,6 +31,9 @@ coverage.candidates_stats_rejected coverage.candidates_timed_out coverage.candidates_validated coverage.candidates_without_stats +coverage.key_probes_skipped +coverage.key_probes_skipped[].reason +coverage.key_probes_skipped[].table coverage.pg_stat_statements coverage.schemas_analyzed coverage.schemas_excluded @@ -67,6 +70,12 @@ findings[].kind findings[].metrics findings[].metrics.rows findings[].object +findings[].suggestion +findings[].suggestion.columns +findings[].suggestion.index_method +findings[].suggestion.key_probe +findings[].suggestion.kind +findings[].suggestion.note generated_at naming_detection naming_detection.column_prefixes @@ -75,10 +84,17 @@ naming_detection.column_prefixes[].occurrences naming_detection.column_prefixes[].share naming_detection.column_suffixes naming_detection.column_suffixes[].affix +naming_detection.column_suffixes[].examples naming_detection.column_suffixes[].occurrences naming_detection.column_suffixes[].share naming_detection.declared_keys naming_detection.enabled +naming_detection.primary_key_names +naming_detection.primary_key_names[].affix +naming_detection.primary_key_names[].examples +naming_detection.primary_key_names[].occurrences +naming_detection.primary_key_names[].share +naming_detection.single_pk_tables naming_detection.table_prefixes naming_detection.table_prefixes[].affix naming_detection.table_prefixes[].occurrences @@ -128,6 +144,8 @@ schemas[].tables[].stats.usage.counters.seq_scans schemas[].tables[].stats.usage.counters.updates schemas[].tables[].stats.usage.stats_reset_at schemas[].tables[].uniques +schemas[].tables[].uniques[].columns +schemas[].tables[].uniques[].name server_version tool tool_version diff --git a/internal/sqlprobe/extract.go b/internal/sqlprobe/extract.go index 1a03a91..ee24dc1 100644 --- a/internal/sqlprobe/extract.go +++ b/internal/sqlprobe/extract.go @@ -14,6 +14,45 @@ type rawRef struct { schema, table, column string } +// predOp is the extractor's own classification of a predicate operator, +// mapped to model.OperatorClass once the reference is resolved against the +// catalog in probe.go. Keeping it local means extract.go stays free of the +// model import, same as rawJoin and rawRef. +type predOp int + +const ( + predEquality predOp = iota + predRange + predLikePrefix + predLikeInfix + predContainment + predFullText + predVectorDistance +) + +// rawPredicate is one predicate on a reference as written in the SQL, not yet +// resolved against the catalog. +type rawPredicate struct { + ref rawRef + op predOp +} + +// comparisonOperators are order comparisons: btree serves all of them. +var comparisonOperators = map[string]bool{ + "<": true, ">": true, "<=": true, ">=": true, "<>": true, "!=": true, +} + +// containmentOperators are jsonb/array containment and key existence: GIN +// serves them, btree does not. +var containmentOperators = map[string]bool{ + "@>": true, "<@": true, "?": true, "?|": true, "?&": true, +} + +// vectorDistanceOperators are pgvector's nearest-neighbor operators. +var vectorDistanceOperators = map[string]bool{ + "<->": true, "<=>": true, "<#>": true, +} + // clauseStoppers end the collection of one FROM item. Anything else after a // table name is read as its alias. var clauseStoppers = map[string]bool{ @@ -25,19 +64,22 @@ var clauseStoppers = map[string]bool{ "tablesample": true, "set": true, "values": true, "select": true, "from": true, } -// extract mines every recognizable join predicate from one piece of SQL. +// extract mines every recognizable join predicate and column predicate from +// one piece of SQL. Join predicates feed relationship inference; column +// predicates feed index method recommendation. // // The alias map is flat per statement: subqueries have their own scopes, and // modelling them is half a parser. On an alias clash across scopes the // resolution can be wrong — and the wrong candidate dies in validation, which // is the trade the package documentation defends. -func extract(sql string) []rawJoin { +func extract(sql string) ([]rawJoin, []rawPredicate) { // A body handed over wrapped in one dollar quote — the CREATE FUNCTION // form — is code, not a string. Embedded dollar quotes deeper in remain // strings: extracting from dynamically assembled SQL would be guessing. sql = unwrapDollarBody(sql) var joins []rawJoin + var preds []rawPredicate tokens := tokenize(sql) aliases := make(map[string]rawRef) @@ -55,16 +97,57 @@ func extract(sql string) []rawJoin { case t.kind == tokSymbol && t.text == "=": left, okL := refEndingAt(tokens, i-1) right, okR := refStartingAt(tokens, i+1) - if okL && okR { + switch { + case okL && okR: joins = append(joins, rawJoin{ left: resolve(left, aliases), right: resolve(right, aliases), }) + case okL: + preds = append(preds, rawPredicate{ref: resolve(left, aliases), op: predEquality}) + } + + case t.kind == tokSymbol && comparisonOperators[t.text]: + if left, ok := refEndingAt(tokens, i-1); ok { + preds = append(preds, rawPredicate{ref: resolve(left, aliases), op: predRange}) + } + + case t.kind == tokSymbol && containmentOperators[t.text]: + if left, ok := refEndingAt(tokens, i-1); ok { + preds = append(preds, rawPredicate{ref: resolve(left, aliases), op: predContainment}) + } + + case t.kind == tokSymbol && t.text == "@@": + if left, ok := refEndingAt(tokens, i-1); ok { + preds = append(preds, rawPredicate{ref: resolve(left, aliases), op: predFullText}) + } + + case t.kind == tokSymbol && vectorDistanceOperators[t.text]: + if left, ok := refEndingAt(tokens, i-1); ok { + preds = append(preds, rawPredicate{ref: resolve(left, aliases), op: predVectorDistance}) + } + + case t.kind == tokIdent && (t.text == "like" || t.text == "ilike"): + if left, ok := refEndingAt(tokens, i-1); ok { + preds = append(preds, rawPredicate{ref: resolve(left, aliases), op: likeOperator(tokens, i+1)}) } } } - return joins + return joins, preds +} + +// likeOperator classifies a LIKE/ILIKE pattern by its leading character. A +// pattern that starts with a wildcard defeats a leading-anchored btree scan +// and wants a trigram index; anything else, including a pattern the extractor +// cannot see (a parameter, a column, a function call), is read as a prefix — +// the conservative choice, since btree is never the wrong recommendation for +// it. +func likeOperator(tokens []token, i int) predOp { + if i < len(tokens) && tokens[i].kind == tokString && strings.HasPrefix(tokens[i].text, "%") { + return predLikeInfix + } + return predLikePrefix } // collectAliases reads the table references after a FROM or JOIN keyword and diff --git a/internal/sqlprobe/extract_test.go b/internal/sqlprobe/extract_test.go index 36fc498..1935f92 100644 --- a/internal/sqlprobe/extract_test.go +++ b/internal/sqlprobe/extract_test.go @@ -8,7 +8,14 @@ import ( func joins(t *testing.T, sql string) []rawJoin { t.Helper() - return extract(sql) + j, _ := extract(sql) + return j +} + +func predicates(t *testing.T, sql string) []rawPredicate { + t.Helper() + _, p := extract(sql) + return p } func pair(lt, lc, rt, rc string) rawJoin { @@ -75,11 +82,22 @@ func TestMultipleJoinsAllExtracted(t *testing.T) { // TestEqualityAgainstLiteralIsNotEvidence pins the bare-column rule: without a // qualifier there is no table to resolve, and p.status_id = 3 is a filter, not -// a join. +// a join. It is still predicate evidence: an equality filter is a hot-column +// signal in its own right. func TestEqualityAgainstLiteralIsNotEvidence(t *testing.T) { - if got := joins(t, `SELECT 1 FROM pedido p WHERE p.status_id = 3 AND p.tipo = 'x'`); len(got) != 0 { + sql := `SELECT 1 FROM pedido p WHERE p.status_id = 3 AND p.tipo = 'x'` + + if got := joins(t, sql); len(got) != 0 { t.Errorf("joins = %+v, want none", got) } + + want := []rawPredicate{ + {ref: rawRef{table: "pedido", column: "status_id"}, op: predEquality}, + {ref: rawRef{table: "pedido", column: "tipo"}, op: predEquality}, + } + if diff := cmp.Diff(want, predicates(t, sql), cmp.AllowUnexported(rawPredicate{}, rawRef{})); diff != "" { + t.Errorf("predicates mismatch (-want +got):\n%s", diff) + } } func TestStringsAndCommentsAreInvisible(t *testing.T) { @@ -124,6 +142,18 @@ func TestOtherOperatorsAreNotEquality(t *testing.T) { if got := joins(t, sql); len(got) != 0 { t.Errorf("joins = %+v, want none: <=, >=, != and <> are not equality", got) } + + // Each left-hand qualified column still produces range predicate evidence, + // which is what a hot-column recommendation needs even when no join formed. + preds := predicates(t, sql) + if len(preds) != 4 { + t.Fatalf("predicates = %+v, want 4 range predicates", preds) + } + for _, p := range preds { + if p.op != predRange { + t.Errorf("predicate %+v: op = %v, want predRange", p, p.op) + } + } } func TestStatementsDoNotShareAliases(t *testing.T) { @@ -150,8 +180,9 @@ func TestMalformedSQLYieldsNothingAndNoPanic(t *testing.T) { "合同 JOIN ON = 数据", } for _, sql := range cases { - if got := extract(sql); len(got) != 0 { - t.Errorf("extract(%q) = %+v, want nothing", sql, got) + j, p := extract(sql) + if len(j) != 0 || len(p) != 0 { + t.Errorf("extract(%q) = joins %+v, predicates %+v, want nothing", sql, j, p) } } } @@ -190,3 +221,93 @@ func TestServerReconstructedViewShape(t *testing.T) { t.Errorf("joins mismatch (-want +got):\n%s", diff) } } + +// TestPredicateContainmentIsClassified covers the jsonb/array operators GIN +// serves and btree does not. +func TestPredicateContainmentIsClassified(t *testing.T) { + cases := []struct { + sql string + op predOp + }{ + {`SELECT 1 FROM t WHERE t.dados @> '{"a":1}'::jsonb`, predContainment}, + {`SELECT 1 FROM t WHERE t.tags <@ '{"a","b"}'::text[]`, predContainment}, + {`SELECT 1 FROM t WHERE t.dados ? 'chave'`, predContainment}, + {`SELECT 1 FROM t WHERE t.dados ?| array['a','b']`, predContainment}, + {`SELECT 1 FROM t WHERE t.dados ?& array['a','b']`, predContainment}, + {`SELECT 1 FROM t WHERE t.busca @@ to_tsquery('portuguese', 'x')`, predFullText}, + } + + for _, c := range cases { + got := predicates(t, c.sql) + if len(got) != 1 || got[0].op != c.op { + t.Errorf("predicates(%q) = %+v, want one predicate with op %v", c.sql, got, c.op) + } + } +} + +// TestPredicateLikeDistinguishesPrefixFromInfix pins the distinction that +// drives the trigram-index recommendation: an infix wildcard defeats a +// leading-anchored btree scan, a prefix pattern does not. +func TestPredicateLikeDistinguishesPrefixFromInfix(t *testing.T) { + sql := `SELECT 1 FROM t WHERE t.nome LIKE '%silva%' AND t.cod ILIKE 'ABC%'` + + got := predicates(t, sql) + want := []rawPredicate{ + {ref: rawRef{table: "t", column: "nome"}, op: predLikeInfix}, + {ref: rawRef{table: "t", column: "cod"}, op: predLikePrefix}, + } + if diff := cmp.Diff(want, got, cmp.AllowUnexported(rawPredicate{}, rawRef{})); diff != "" { + t.Errorf("predicates mismatch (-want +got):\n%s", diff) + } +} + +// TestPredicateLikeWithUnseenRightHandSideDefaultsToPrefix covers a pattern +// the extractor cannot read — a parameter or a function call — which must +// never be guessed as infix. +func TestPredicateLikeWithUnseenRightHandSideDefaultsToPrefix(t *testing.T) { + sql := `SELECT 1 FROM t WHERE t.nome LIKE upper('x')` + + got := predicates(t, sql) + if len(got) != 1 || got[0].op != predLikePrefix { + t.Errorf("predicates = %+v, want one predLikePrefix", got) + } +} + +// TestPredicateVectorDistanceIsClassified covers pgvector's nearest-neighbor +// operators. +func TestPredicateVectorDistanceIsClassified(t *testing.T) { + for _, op := range []string{"<->", "<=>", "<#>"} { + sql := `SELECT 1 FROM t ORDER BY t.embedding ` + op + ` '[1,2,3]' LIMIT 10` + got := predicates(t, sql) + if len(got) != 1 || got[0].op != predVectorDistance { + t.Errorf("predicates(%q) = %+v, want one predVectorDistance", sql, got) + } + } +} + +// TestUnrecognizedOperatorProducesNoPredicateAndNoPanic pins the extractor's +// central contract: an operator it does not classify is ignored, never +// guessed, and never a crash. +func TestUnrecognizedOperatorProducesNoPredicateAndNoPanic(t *testing.T) { + sql := `SELECT 1 FROM t WHERE t.nome ~ '^abc' AND t.nome !~ 'xyz'` + if got := predicates(t, sql); len(got) != 0 { + t.Errorf("predicates = %+v, want none for an unrecognized operator", got) + } +} + +// TestJoinExtractionIsUnaffectedByPredicateExtraction pins the requirement +// that the existing join contract does not change now that predicates are +// extracted alongside it. +func TestJoinExtractionIsUnaffectedByPredicateExtraction(t *testing.T) { + sql := `SELECT 1 FROM pedido p JOIN cliente c ON p.cliente_id = c.id WHERE p.valor > 100` + + wantJoins := []rawJoin{pair("pedido", "cliente_id", "cliente", "id")} + if diff := cmp.Diff(wantJoins, joins(t, sql), cmp.AllowUnexported(rawJoin{}, rawRef{})); diff != "" { + t.Errorf("joins mismatch (-want +got):\n%s", diff) + } + + wantPreds := []rawPredicate{{ref: rawRef{table: "pedido", column: "valor"}, op: predRange}} + if diff := cmp.Diff(wantPreds, predicates(t, sql), cmp.AllowUnexported(rawPredicate{}, rawRef{})); diff != "" { + t.Errorf("predicates mismatch (-want +got):\n%s", diff) + } +} diff --git a/internal/sqlprobe/probe.go b/internal/sqlprobe/probe.go index 26dbde8..f2f9c31 100644 --- a/internal/sqlprobe/probe.go +++ b/internal/sqlprobe/probe.go @@ -49,6 +49,13 @@ const queryStatements = `SELECT query FROM pg_stat_statements` type Evidence struct { Joins []model.JoinEvidence + // Predicates is column-level predicate evidence: what operator real code + // applies to a column, which drives index method recommendation. Unlike + // Joins, an entry survives per distinct object — recurrence across views + // and functions is the signal a hot-column finding needs, and undercounting + // it is the safe direction, never the false-positive one. + Predicates []model.PredicateEvidence + // StatementsAvailable reports whether pg_stat_statements answered. Absence // of usage evidence must never look like absence of usage. StatementsAvailable bool @@ -65,25 +72,30 @@ func Probe(ctx context.Context, q Querier, schemas []model.Schema) (*Evidence, e } ev := &Evidence{} - var raw []sourcedJoin + var rawJoins []sourcedJoin + var rawPreds []sourcedPredicate views, err := fetchSources(ctx, q, queryViews, names) if err != nil { return nil, fmt.Errorf("reading view definitions: %w", err) } - raw = append(raw, extractAll(views, model.JoinFromView)...) + j, p := extractAll(views, model.JoinFromView) + rawJoins, rawPreds = append(rawJoins, j...), append(rawPreds, p...) functions, err := fetchSources(ctx, q, queryFunctions, names) if err != nil { return nil, fmt.Errorf("reading function bodies: %w", err) } - raw = append(raw, extractAll(functions, model.JoinFromFunction)...) + j, p = extractAll(functions, model.JoinFromFunction) + rawJoins, rawPreds = append(rawJoins, j...), append(rawPreds, p...) statements, available := fetchStatements(ctx, q) ev.StatementsAvailable = available - raw = append(raw, extractAll(statements, model.JoinFromStatements)...) + j, p = extractAll(statements, model.JoinFromStatements) + rawJoins, rawPreds = append(rawJoins, j...), append(rawPreds, p...) - ev.Joins = resolveAgainstCatalog(raw, schemas) + ev.Joins = resolveAgainstCatalog(rawJoins, schemas) + ev.Predicates = resolvePredicatesAgainstCatalog(rawPreds, schemas) return ev, nil } @@ -99,6 +111,12 @@ type sourcedJoin struct { object string } +type sourcedPredicate struct { + pred rawPredicate + source model.JoinSource + object string +} + func fetchSources(ctx context.Context, q Querier, query string, schemas []string) ([]source, error) { rows, err := q.Query(ctx, query, schemas) if err != nil { @@ -145,14 +163,19 @@ func fetchStatements(ctx context.Context, q Querier) ([]source, bool) { return out, true } -func extractAll(sources []source, kind model.JoinSource) []sourcedJoin { - var out []sourcedJoin +func extractAll(sources []source, kind model.JoinSource) ([]sourcedJoin, []sourcedPredicate) { + var joins []sourcedJoin + var preds []sourcedPredicate for _, s := range sources { - for _, j := range extract(s.sql) { - out = append(out, sourcedJoin{join: j, source: kind, object: s.object}) + j, p := extract(s.sql) + for _, jj := range j { + joins = append(joins, sourcedJoin{join: jj, source: kind, object: s.object}) + } + for _, pp := range p { + preds = append(preds, sourcedPredicate{pred: pp, source: kind, object: s.object}) } } - return out + return joins, preds } // resolveAgainstCatalog keeps only pairs whose both sides name a real column @@ -209,6 +232,73 @@ func resolveAgainstCatalog(raw []sourcedJoin, schemas []model.Schema) []model.Jo return out } +// resolvePredicatesAgainstCatalog keeps only predicates whose reference names a +// real column of a real table in scope. Unlike join resolution, an entry +// survives per distinct object: recurrence across views and functions is +// exactly the signal a hot-column finding needs. +func resolvePredicatesAgainstCatalog(raw []sourcedPredicate, schemas []model.Schema) []model.PredicateEvidence { + type dedupKey struct { + column string + op model.OperatorClass + source model.JoinSource + object string + } + + seen := make(map[dedupKey]bool) + var out []model.PredicateEvidence + + for _, sp := range raw { + col, ok := resolveRef(sp.pred.ref, schemas) + if !ok { + continue + } + + op := operatorClassFor(sp.pred.op) + key := dedupKey{column: col.String(), op: op, source: sp.source, object: sp.object} + if seen[key] { + continue + } + seen[key] = true + + out = append(out, model.PredicateEvidence{ + Column: col, + Operator: op, + Source: sp.source, + Object: sp.object, + }) + } + + sort.SliceStable(out, func(i, j int) bool { + if out[i].Column.String() != out[j].Column.String() { + return out[i].Column.String() < out[j].Column.String() + } + if out[i].Operator != out[j].Operator { + return out[i].Operator < out[j].Operator + } + return out[i].Object < out[j].Object + }) + return out +} + +func operatorClassFor(op predOp) model.OperatorClass { + switch op { + case predRange: + return model.OpRange + case predLikePrefix: + return model.OpLikePrefix + case predLikeInfix: + return model.OpLikeInfix + case predContainment: + return model.OpContainment + case predFullText: + return model.OpFullText + case predVectorDistance: + return model.OpVectorDistance + default: + return model.OpEquality + } +} + func resolveRef(ref rawRef, schemas []model.Schema) (model.ColumnRef, bool) { var found model.ColumnRef matches := 0 diff --git a/internal/sqlprobe/token.go b/internal/sqlprobe/token.go index c9acc8b..a8cdb95 100644 --- a/internal/sqlprobe/token.go +++ b/internal/sqlprobe/token.go @@ -26,6 +26,13 @@ const ( // tokOther is anything the extractor must see as "not a name": numbers, // parameters, unrecognized bytes. tokOther + + // tokString is a string literal's content, unescaped. It exists only so a + // LIKE pattern can be read to tell an infix wildcard from a prefix one; + // nothing else inspects it, and no equals sign inside one was ever tokenized + // in the first place, so this adds no new way for a phantom predicate to + // appear. + tokString ) type token struct { @@ -33,9 +40,11 @@ type token struct { text string } -// tokenize walks the SQL and emits only what extraction needs. Comments, -// strings and dollar-quoted blocks are consumed and never emitted: an "=" -// inside any of them would otherwise become a phantom predicate. +// tokenize walks the SQL and emits only what extraction needs. Comments and +// dollar-quoted blocks are consumed and never emitted. A string literal is +// emitted as one opaque tokString carrying its content — never re-tokenized — +// so an "=" or other operator inside one can never become a phantom +// predicate; the content exists only so a LIKE pattern can be read. func tokenize(sql string) []token { var out []token i := 0 @@ -54,7 +63,9 @@ func tokenize(sql string) []token { i = skipBlockComment(sql, i+2) case c == '\'': - i = skipString(sql, i+1, isEscapePrefixed(sql, i)) + text, next := readString(sql, i+1, isEscapePrefixed(sql, i)) + out = append(out, token{kind: tokString, text: text}) + i = next case c == '$': next, ok := skipDollarQuoted(sql, i) @@ -133,23 +144,29 @@ func isEscapePrefixed(s string, i int) bool { (i < 2 || !isIdentPart(s[i-2])) } -func skipString(s string, i int, backslashEscapes bool) int { +func readString(s string, i int, backslashEscapes bool) (string, int) { + var b strings.Builder for i < len(s) { switch { case backslashEscapes && s[i] == '\\': + if i+1 < len(s) { + b.WriteByte(s[i+1]) + } i += 2 case s[i] == '\'': // '' is a literal quote, not the end. if hasAt(s, i+1, '\'') { + b.WriteByte('\'') i += 2 continue } - return i + 1 + return b.String(), i + 1 default: + b.WriteByte(s[i]) i++ } } - return i + return b.String(), i } // skipDollarQuoted consumes a $tag$...$tag$ block starting at i. It reports diff --git a/internal/testutil/fixtures_integration_test.go b/internal/testutil/fixtures_integration_test.go index 96973e4..8a0c037 100644 --- a/internal/testutil/fixtures_integration_test.go +++ b/internal/testutil/fixtures_integration_test.go @@ -23,9 +23,15 @@ import ( var knownFixtures = []string{ "clean_schema", "composite_keys", + "hot_column_unindexed", "inferable", + "jsonb_containment", + "missing_pk_composite", + "missing_pk_fk_bridge", + "missing_pk_promotable", "no_constraints", "not_valid_constraints", + "pgvector_unindexed", "restricted_privileges", "stats_prefilter", "unindexed_fks", diff --git a/internal/testutil/leak.go b/internal/testutil/leak.go index f8ea835..6e2cefd 100644 --- a/internal/testutil/leak.go +++ b/internal/testutil/leak.go @@ -23,8 +23,11 @@ import ( var PlantedValues = []string{ "145.892.663-04", "529.318.470-11", + "Areia media m3", "Bomba Centrifuga", + "Brita 1 m3", "CT-2019-0041", + "Cimento CP-II 50kg", "Compressor Industrial", "Construtora Horizonte LTDA", "Conta Corrente", @@ -33,15 +36,18 @@ var PlantedValues = []string{ "Filial Oeste", "Fornecedor Municipal", "Joao Carlos Pereira", + "Laudo tecnico", "Leitura Manual Bloco C", "Maria Aparecida Silva", "Matriz Central", + "Memorial descritivo", "Ponto de Coleta", "Prefeitura de Sao Bernardo", "Rua das Acacias 42", "Sao Bernardo do Campo", "Secretaria de Obras", "Secretaria de Saude", + "Tijolo ceramico", "peca de reposicao", "servico de manutencao", } diff --git a/internal/testutil/postgres_integration.go b/internal/testutil/postgres_integration.go index 8a95ab6..32b1759 100644 --- a/internal/testutil/postgres_integration.go +++ b/internal/testutil/postgres_integration.go @@ -99,6 +99,45 @@ func postgresContainer(t *testing.T, image, script string) string { return dsn } +// TryPostgresImageDSN is PostgresImageDSN, but skips the test instead of +// failing it when the image cannot be pulled or the container cannot start. +// It exists for a fixture that depends on an image not every environment can +// reach — pgvector's, in particular, which is not cached wherever the +// standard postgres image already is. +func TryPostgresImageDSN(t *testing.T, image, fixture string) (string, bool) { + t.Helper() + + ctx := context.Background() + + container, err := postgres.Run(ctx, image, + postgres.WithDatabase("pgfathom_test"), + postgres.WithUsername("pgfathom_test"), + postgres.WithPassword("pgfathom_test"), + postgres.WithInitScripts(fixturePath(t, fixture)), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(2*time.Minute), + ), + ) + if err != nil { + t.Logf("starting PostgreSQL image %s: %v", image, err) + return "", false + } + + t.Cleanup(func() { + if err := testcontainers.TerminateContainer(container); err != nil { + t.Logf("terminating container: %v", err) + } + }) + + dsn, err := container.ConnectionString(ctx, "sslmode=disable") + if err != nil { + t.Fatalf("resolving connection string: %v", err) + } + return dsn, true +} + // fixturePath resolves a fixture against this package's source directory, not // against the working directory. // diff --git a/internal/testutil/testdata/hot_column_unindexed.sql b/internal/testutil/testdata/hot_column_unindexed.sql new file mode 100644 index 0000000..ab03eeb --- /dev/null +++ b/internal/testutil/testdata/hot_column_unindexed.sql @@ -0,0 +1,33 @@ +-- Cenário: coluna recorrente em predicado de junção e de filtro no código +-- real, sem índice liderando ela. Duas fontes distintas — uma view e uma +-- função — tocam a mesma coluna, o suficiente para passar do limiar de +-- recorrência padrão. + +CREATE TABLE centro_custo ( + id bigint PRIMARY KEY, + nome text NOT NULL +); + +CREATE TABLE movimentacao ( + id bigint PRIMARY KEY, + centro_custo_id bigint NOT NULL, -- sem índice de propósito: é o achado + valor numeric(12,2) NOT NULL +); + +CREATE VIEW vw_movimentacao_por_centro AS +SELECT m.id, c.nome AS centro, m.valor +FROM movimentacao m +JOIN centro_custo c ON m.centro_custo_id = c.id; + +CREATE FUNCTION fn_total_centro(p_centro bigint) +RETURNS numeric +LANGUAGE sql +AS $$ + SELECT sum(m.valor) FROM movimentacao m WHERE m.centro_custo_id = p_centro; +$$; + +INSERT INTO centro_custo (id, nome) VALUES (1, 'Secretaria de Obras'), (2, 'Secretaria de Saude'); +INSERT INTO movimentacao (id, centro_custo_id, valor) VALUES + (1, 1, 1000.00), (2, 1, 2500.00), (3, 2, 300.00); + +ANALYZE; diff --git a/internal/testutil/testdata/jsonb_containment.sql b/internal/testutil/testdata/jsonb_containment.sql new file mode 100644 index 0000000..d5519f0 --- /dev/null +++ b/internal/testutil/testdata/jsonb_containment.sql @@ -0,0 +1,26 @@ +-- Cenário: coluna jsonb sondada por contenção (@>) em duas fontes distintas, +-- sem índice GIN. GIN tem classe de operador padrão para jsonb, então a +-- recomendação não depende de extensão nenhuma. + +CREATE TABLE processo ( + id bigint PRIMARY KEY, + dados jsonb NOT NULL +); + +CREATE VIEW vw_processo_urgente AS +SELECT p.id +FROM processo p +WHERE p.dados @> '{"prioridade": "urgente"}'::jsonb; + +CREATE FUNCTION fn_processos_com_tag(p_tag jsonb) +RETURNS SETOF bigint +LANGUAGE sql +AS $$ + SELECT p.id FROM processo p WHERE p.dados @> p_tag; +$$; + +INSERT INTO processo (id, dados) VALUES + (1, '{"prioridade": "urgente", "setor": "Secretaria de Obras"}'), + (2, '{"prioridade": "normal", "setor": "Secretaria de Saude"}'); + +ANALYZE; diff --git a/internal/testutil/testdata/missing_pk_composite.sql b/internal/testutil/testdata/missing_pk_composite.sql new file mode 100644 index 0000000..ba99d40 --- /dev/null +++ b/internal/testutil/testdata/missing_pk_composite.sql @@ -0,0 +1,41 @@ +-- Cenário: tabela sem PK e sem nenhum UNIQUE declarado, mas com um índice não +-- único sobre duas colunas NOT NULL cuja combinação é, na prática, única. É o +-- caso que só uma sondagem por contagem contra os dados resolve — o catálogo +-- não tem como provar isto sozinho. + +CREATE TABLE item_pedido ( + pedido_id bigint NOT NULL, + sequencia int NOT NULL, + produto text NOT NULL, + quantidade numeric(10,2) NOT NULL +); + +-- Não-único de propósito: o schema já agrupou estas colunas, mas nunca +-- declarou a constraint. A sondagem é o que confirma. +CREATE INDEX ix_item_pedido_pedido_sequencia ON item_pedido (pedido_id, sequencia); + +INSERT INTO item_pedido (pedido_id, sequencia, produto, quantidade) VALUES + (1, 1, 'Cimento CP-II 50kg', 10), + (1, 2, 'Areia media m3', 3), + (2, 1, 'Cimento CP-II 50kg', 25), + (2, 2, 'Brita 1 m3', 5), + (3, 1, 'Tijolo ceramico', 500); + +-- Segundo cenário no mesmo arquivo: colunas com a mesma forma, mas com uma +-- duplicata plantada. A sondagem tem que dizer "não é chave", nunca confirmar +-- por engano. +CREATE TABLE pagamento_parcela ( + contrato_id bigint NOT NULL, + parcela int NOT NULL, + valor numeric(10,2) NOT NULL +); + +CREATE INDEX ix_pagamento_parcela_contrato_parcela ON pagamento_parcela (contrato_id, parcela); + +INSERT INTO pagamento_parcela (contrato_id, parcela, valor) VALUES + (1, 1, 500.00), + (1, 2, 500.00), + (1, 2, 500.00), -- duplicata plantada: (contrato_id, parcela) = (1, 2) duas vezes + (2, 1, 300.00); + +ANALYZE; diff --git a/internal/testutil/testdata/missing_pk_fk_bridge.sql b/internal/testutil/testdata/missing_pk_fk_bridge.sql new file mode 100644 index 0000000..d1b5fd8 --- /dev/null +++ b/internal/testutil/testdata/missing_pk_fk_bridge.sql @@ -0,0 +1,50 @@ +-- Cenario: tabela-ponte sem PK e sem nenhum indice ou unique sobre suas duas +-- FKs de coluna unica. candidateKeys (a heuristica automatica de audit) so +-- olha para colunas de um indice nao-unico ja existente, entao nunca chega a +-- tentar este par -- e' exatamente a lacuna que a resolucao interativa fecha, +-- oferecendo a combinacao das FKs como candidato. +-- +-- pedido, produto e situacao dao ao schema tres tabelas com PK de coluna +-- unica chamada "idkey" -- o minimo que profile.Detect exige para tabular uma +-- convencao de nome de PK (minPKNameCount = 3), o que deixa a opcao de coluna +-- sintetica da resolucao interativa testavel. + +CREATE TABLE pedido ( + idkey bigint PRIMARY KEY, + numero text NOT NULL +); + +CREATE TABLE produto ( + idkey bigint PRIMARY KEY, + nome text NOT NULL +); + +CREATE TABLE situacao ( + idkey bigint PRIMARY KEY, + descricao text NOT NULL +); + +INSERT INTO situacao (idkey, descricao) VALUES (1, 'Aberta'), (2, 'Fechada'); + +CREATE TABLE pedido_produto ( + pedido_id bigint NOT NULL REFERENCES pedido (idkey), + produto_id bigint NOT NULL REFERENCES produto (idkey), + quantidade numeric(10,2) NOT NULL +); + +INSERT INTO pedido (idkey, numero) VALUES (1, 'PED-1'), (2, 'PED-2'), (3, 'PED-3'); +INSERT INTO produto (idkey, nome) VALUES (10, 'Cimento'), (20, 'Areia'), (30, 'Brita'); + +-- (pedido_id, produto_id) nunca se repete: e' uma chave composta real, so' +-- que sem indice ou constraint nenhuma provando isso. quantidade repete de +-- proposito (10.00 e 25.00 aparecem duas vezes cada), assim como pedido_id e +-- produto_id isoladamente: nenhuma coluna sozinha pode parecer, por +-- coincidencia dos dados plantados, uma chave candidata valida. +INSERT INTO pedido_produto (pedido_id, produto_id, quantidade) VALUES + (1, 10, 10.00), + (1, 20, 10.00), + (2, 10, 25.00), + (2, 30, 25.00), + (3, 10, 500.00); + +ANALYZE; diff --git a/internal/testutil/testdata/missing_pk_promotable.sql b/internal/testutil/testdata/missing_pk_promotable.sql new file mode 100644 index 0000000..d5223ad --- /dev/null +++ b/internal/testutil/testdata/missing_pk_promotable.sql @@ -0,0 +1,24 @@ +-- Cenário: tabela sem PK, mas com uma constraint UNIQUE cujas colunas são +-- todas NOT NULL. O catálogo já prova a chave — nenhuma sondagem de dado é +-- necessária, só a promoção. + +CREATE TABLE cadastro_pessoa ( + id bigint NOT NULL, + cpf text NOT NULL, + nome text, + UNIQUE (cpf) +); + +-- Suportada, para provar que o achado não engole tabela saudável. +CREATE TABLE municipio ( + id bigint PRIMARY KEY, + nome text NOT NULL +); + +INSERT INTO cadastro_pessoa (id, cpf, nome) VALUES + (1, '529.318.470-11', 'Maria Aparecida Silva'), + (2, '145.892.663-04', 'Joao Carlos Pereira'); + +INSERT INTO municipio (id, nome) VALUES (1, 'Sao Bernardo do Campo'); + +ANALYZE; diff --git a/internal/testutil/testdata/pgvector_unindexed.sql b/internal/testutil/testdata/pgvector_unindexed.sql new file mode 100644 index 0000000..177ef74 --- /dev/null +++ b/internal/testutil/testdata/pgvector_unindexed.sql @@ -0,0 +1,31 @@ +-- Cenário: coluna vector sondada por operador de distância em duas fontes +-- distintas, sem índice de vizinhança. Só roda contra uma imagem que já tem +-- pgvector instalada — a suíte que carrega esta fixture escolhe essa imagem +-- e pula o teste quando não consegue subir o contêiner. + +CREATE EXTENSION IF NOT EXISTS vector; + +CREATE TABLE documento ( + id bigint PRIMARY KEY, + titulo text NOT NULL, + embedding vector(3) NOT NULL +); + +CREATE VIEW vw_documento_similar AS +SELECT d.id, d.titulo +FROM documento d +ORDER BY d.embedding <-> '[0,0,0]' +LIMIT 5; + +CREATE FUNCTION fn_documentos_proximos(p_ref vector(3)) +RETURNS TABLE (id bigint) +LANGUAGE sql +AS $$ + SELECT d.id FROM documento d ORDER BY d.embedding <-> p_ref LIMIT 5; +$$; + +INSERT INTO documento (id, titulo, embedding) VALUES + (1, 'Memorial descritivo', '[0.1, 0.2, 0.3]'), + (2, 'Laudo tecnico', '[0.9, 0.8, 0.7]'); + +ANALYZE; diff --git a/internal/validate/keyprobe.go b/internal/validate/keyprobe.go new file mode 100644 index 0000000..8308662 --- /dev/null +++ b/internal/validate/keyprobe.go @@ -0,0 +1,131 @@ +package validate + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/lvcas-dotcom/pgfathom/internal/model" +) + +// KeyProbeResult is the outcome of testing one candidate column set for +// uniqueness by counting rows against the data. +type KeyProbeResult struct { + Columns []string + + // Verdict is KeyProbeConfirmed when a full scan proved total rows equal + // distinct values and zero rows are NULL in any key column. Otherwise it + // is KeyProbeUnverified — whether because the columns are genuinely not + // unique, the probe timed out, or the role lacked privilege. There is no + // third state: a proven duplicate and an inconclusive probe both mean "no + // key proven here", which is all a caller needs to decide what to do next. + Verdict model.KeyProbeVerdict + + // Reason explains an Unverified verdict. Object names and conditions only. + Reason string +} + +// ProbeUniqueness tests each candidate column set of table for uniqueness by +// counting rows: the total, the count of distinct values across the set, and +// how many rows are NULL in any column of it. Only these three integers ever +// leave the query — no value from a row is read. +// +// A candidate is confirmed only by a full table scan. TABLESAMPLE never +// appears here: a single duplicate outside the sample would make the +// confirmation false, and unlike relationship validation, this package has no +// weaker claim to make about a primary key than "proven". Candidates are +// probed one at a time, deliberately: naming a key is rare enough, and the +// scan expensive enough, that running several full scans of the same table +// concurrently is not a cost worth adding for it. +// +// An error is returned only for a failure the caller cannot act on by moving +// to the next candidate — the same split validateOne makes between a +// resolved per-candidate outcome and an infrastructure failure that makes +// every remaining result untrustworthy too. +func ProbeUniqueness(ctx context.Context, pool Beginner, table model.Table, candidates [][]string, timeout time.Duration) ([]KeyProbeResult, error) { + out := make([]KeyProbeResult, len(candidates)) + + for i, cols := range candidates { + res, err := probeOne(ctx, pool, table, cols, timeout) + if err != nil { + return nil, fmt.Errorf("probing uniqueness of %s(%s): %w", table.Ref(), strings.Join(cols, ", "), err) + } + out[i] = res + } + + return out, nil +} + +func probeOne(ctx context.Context, pool Beginner, table model.Table, cols []string, timeout time.Duration) (KeyProbeResult, error) { + res := KeyProbeResult{Columns: cols} + + total, distinct, nulls, err := runKeyProbeQuery(ctx, pool, buildKeyProbeQuery(table, cols), timeout) + if err != nil { + reason, _, resolved := resolveError(ctx, err) + if !resolved { + return KeyProbeResult{}, err + } + res.Verdict = model.KeyProbeUnverified + res.Reason = reason + return res, nil + } + + if total == distinct && nulls == 0 { + res.Verdict = model.KeyProbeConfirmed + return res, nil + } + + res.Verdict = model.KeyProbeUnverified + res.Reason = "columns are not unique" + return res, nil +} + +// buildKeyProbeQuery assembles the count-only uniqueness check. Identifiers +// are quoted without exception, the same discipline buildQuery follows for +// relationship validation. +// +// (col1, col2, ...) is a row constructor for two or more columns and a plain +// parenthesized expression for one, so the same shape serves a single-column +// or a composite candidate without a special case. +func buildKeyProbeQuery(table model.Table, columns []string) string { + rel := pgx.Identifier{table.Schema, table.Name}.Sanitize() + + quoted := make([]string, len(columns)) + nullChecks := make([]string, len(columns)) + for i, c := range columns { + q := pgx.Identifier{c}.Sanitize() + quoted[i] = q + nullChecks[i] = q + " IS NULL" + } + tuple := "(" + strings.Join(quoted, ", ") + ")" + + return fmt.Sprintf(` + SELECT + count(*) AS total_rows, + count(DISTINCT %[1]s) AS distinct_vals, + count(*) FILTER (WHERE %[2]s) AS null_rows + FROM %[3]s`, + tuple, strings.Join(nullChecks, " OR "), rel) +} + +// runKeyProbeQuery executes one probe inside its own read-only transaction, +// so the SET LOCAL ceiling dies with it — the same isolation runQuery uses. +func runKeyProbeQuery(ctx context.Context, pool Beginner, query string, timeout time.Duration) (total, distinct, nulls int64, err error) { + tx, err := pool.Begin(ctx) + if err != nil { + return 0, 0, 0, err + } + defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }() + + if timeout > 0 { + if _, err := tx.Exec(ctx, fmt.Sprintf("SET LOCAL statement_timeout = %d", statementTimeoutMillis(timeout))); err != nil { + return 0, 0, 0, err + } + } + + err = tx.QueryRow(ctx, query).Scan(&total, &distinct, &nulls) + return total, distinct, nulls, err +} diff --git a/internal/validate/keyprobe_integration_test.go b/internal/validate/keyprobe_integration_test.go new file mode 100644 index 0000000..a3e1bdd --- /dev/null +++ b/internal/validate/keyprobe_integration_test.go @@ -0,0 +1,108 @@ +//go:build integration + +package validate_test + +import ( + "context" + "testing" + "time" + + "github.com/lvcas-dotcom/pgfathom/internal/db" + "github.com/lvcas-dotcom/pgfathom/internal/model" + "github.com/lvcas-dotcom/pgfathom/internal/testutil" + "github.com/lvcas-dotcom/pgfathom/internal/validate" +) + +// keyProbePool opens a pool against the missing_pk_composite fixture, which +// carries two tables of the same shape — one with a real composite key, one +// with a planted duplicate on it. +func keyProbePool(t *testing.T) (*db.Pool, context.Context) { + t.Helper() + + ctx := context.Background() + cfg := db.DefaultConfig() + cfg.DSN = testutil.Postgres(t, "missing_pk_composite") + + pool, err := db.Open(ctx, cfg) + if err != nil { + t.Fatalf("opening pool: %v", err) + } + t.Cleanup(pool.Close) + + return pool, ctx +} + +// TestProbeUniquenessConfirmsARealKey covers the confirming path: a full scan +// finds total rows equal distinct values with zero nulls. +func TestProbeUniquenessConfirmsARealKey(t *testing.T) { + pool, ctx := keyProbePool(t) + table := model.Table{Schema: "public", Name: "item_pedido"} + + results, err := validate.ProbeUniqueness(ctx, pool, table, [][]string{{"pedido_id", "sequencia"}}, 0) + if err != nil { + t.Fatalf("ProbeUniqueness: %v", err) + } + if len(results) != 1 || results[0].Verdict != model.KeyProbeConfirmed { + t.Fatalf("results = %+v, want one confirmed result", results) + } +} + +// TestProbeUniquenessNeverConfirmsARealDuplicate is the assertion the whole +// feature exists to make: the same shape of candidate, but the data actually +// has a duplicate, must never come back confirmed. +func TestProbeUniquenessNeverConfirmsARealDuplicate(t *testing.T) { + pool, ctx := keyProbePool(t) + table := model.Table{Schema: "public", Name: "pagamento_parcela"} + + results, err := validate.ProbeUniqueness(ctx, pool, table, [][]string{{"contrato_id", "parcela"}}, 0) + if err != nil { + t.Fatalf("ProbeUniqueness: %v", err) + } + if len(results) != 1 || results[0].Verdict != model.KeyProbeUnverified { + t.Fatalf("results = %+v, want one unverified result: a duplicate exists", results) + } + if results[0].Reason == "" { + t.Error("an unverified result must carry a reason") + } +} + +// TestProbeUniquenessTimeoutResolvesWithoutAborting proves an absurdly short +// ceiling turns into an Unverified result, not an error the caller has to +// abort a whole run over. +func TestProbeUniquenessTimeoutResolvesWithoutAborting(t *testing.T) { + pool, ctx := keyProbePool(t) + table := model.Table{Schema: "public", Name: "item_pedido"} + + results, err := validate.ProbeUniqueness(ctx, pool, table, [][]string{{"pedido_id", "sequencia"}}, time.Nanosecond) + if err != nil { + t.Fatalf("ProbeUniqueness: %v", err) + } + if len(results) != 1 || results[0].Verdict != model.KeyProbeUnverified { + t.Fatalf("results = %+v, want one unverified result under a 1ns ceiling", results) + } +} + +// TestProbeUniquenessMultipleCandidatesAreIndependent proves one candidate's +// outcome does not leak into another's: the confirmed and the duplicate +// candidate probed together must each carry their own verdict. +func TestProbeUniquenessMultipleCandidatesAreIndependent(t *testing.T) { + pool, ctx := keyProbePool(t) + table := model.Table{Schema: "public", Name: "item_pedido"} + + results, err := validate.ProbeUniqueness(ctx, pool, table, [][]string{ + {"pedido_id", "sequencia"}, // real key + {"pedido_id"}, // not unique on its own: three rows share pedido_id 1 and 2 + }, 0) + if err != nil { + t.Fatalf("ProbeUniqueness: %v", err) + } + if len(results) != 2 { + t.Fatalf("got %d results, want 2", len(results)) + } + if results[0].Verdict != model.KeyProbeConfirmed { + t.Errorf("(pedido_id, sequencia) = %+v, want confirmed", results[0]) + } + if results[1].Verdict != model.KeyProbeUnverified { + t.Errorf("(pedido_id) alone = %+v, want unverified: it repeats", results[1]) + } +} diff --git a/internal/validate/keyprobe_test.go b/internal/validate/keyprobe_test.go new file mode 100644 index 0000000..2ee2c40 --- /dev/null +++ b/internal/validate/keyprobe_test.go @@ -0,0 +1,65 @@ +package validate + +import ( + "strings" + "testing" + + "github.com/lvcas-dotcom/pgfathom/internal/model" +) + +func TestKeyProbeQueryQuotesIdentifiers(t *testing.T) { + table := model.Table{Schema: "public", Name: `Ordem Servico`} + + q := buildKeyProbeQuery(table, []string{"cliente_id", `uni"dade_id`}) + + for _, want := range []string{`"Ordem Servico"`, `"cliente_id"`, `"uni""dade_id"`} { + if !strings.Contains(q, want) { + t.Errorf("query must quote %s; got:\n%s", want, q) + } + } +} + +// TestKeyProbeQuerySingleColumnUsesSameTupleShape pins the reason the query +// builder needs no special case for a single-column candidate: a +// one-element parenthesized expression is not a row constructor in +// PostgreSQL, so count(DISTINCT (col)) already means the same thing as +// count(DISTINCT col). +func TestKeyProbeQuerySingleColumnUsesSameTupleShape(t *testing.T) { + table := model.Table{Schema: "public", Name: "cliente"} + + q := buildKeyProbeQuery(table, []string{"cpf"}) + + if !strings.Contains(q, `count(DISTINCT ("cpf"))`) { + t.Errorf("query must count distinct over the single-column tuple; got:\n%s", q) + } + if !strings.Contains(q, `"cpf" IS NULL`) { + t.Errorf("query must check the column for NULL; got:\n%s", q) + } +} + +func TestKeyProbeQueryCompositeChecksAllColumnsForNull(t *testing.T) { + table := model.Table{Schema: "public", Name: "item_pedido"} + + q := buildKeyProbeQuery(table, []string{"pedido_id", "sequencia"}) + + if !strings.Contains(q, `count(DISTINCT ("pedido_id", "sequencia"))`) { + t.Errorf("query must count distinct over the composite tuple; got:\n%s", q) + } + if !strings.Contains(q, `"pedido_id" IS NULL OR "sequencia" IS NULL`) { + t.Errorf("query must flag a row NULL in any key column; got:\n%s", q) + } +} + +// TestKeyProbeQueryNeverSamples pins the rule that confirming a key never +// reads a fraction of the table: TABLESAMPLE must never appear in this +// query, because a duplicate outside the sample would make a "confirmed" +// verdict false. +func TestKeyProbeQueryNeverSamples(t *testing.T) { + table := model.Table{Schema: "public", Name: "cliente"} + + q := buildKeyProbeQuery(table, []string{"cpf"}) + + if strings.Contains(q, "TABLESAMPLE") { + t.Errorf("key probe query must never sample; got:\n%s", q) + } +} diff --git a/internal/validate/validate.go b/internal/validate/validate.go index 4c6b6b2..a82b311 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -188,6 +188,23 @@ func validateOne(ctx context.Context, pool Beginner, c model.Candidate, rows map return c, false, nil } +// statementTimeoutMillis converts a positive timeout to the integer +// milliseconds statement_timeout expects, rounded up rather than truncated. +// Duration.Milliseconds truncates towards zero, so any caller-supplied +// timeout under 1ms — an aggressive per-candidate ceiling, or a test proving +// the timeout path — would otherwise floor to 0, and 0 means "disabled" to +// Postgres: the one ceiling meant to fire the most reliably would instead be +// the one silently switched off. Callers already treat timeout <= 0 as "leave +// the session default alone" before this is reached, so a positive input here +// always means a real ceiling was asked for and must never resolve to none. +func statementTimeoutMillis(timeout time.Duration) int64 { + ms := timeout.Milliseconds() + if ms < 1 { + ms = 1 + } + return ms +} + // runQuery executes one validation inside its own read-only transaction, so // the SET LOCAL ceiling dies with it. func runQuery(ctx context.Context, pool Beginner, query string, timeout time.Duration) (model.Validation, error) { @@ -200,7 +217,7 @@ func runQuery(ctx context.Context, pool Beginner, query string, timeout time.Dur defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }() if timeout > 0 { - if _, err := tx.Exec(ctx, fmt.Sprintf("SET LOCAL statement_timeout = %d", timeout.Milliseconds())); err != nil { + if _, err := tx.Exec(ctx, fmt.Sprintf("SET LOCAL statement_timeout = %d", statementTimeoutMillis(timeout))); err != nil { return v, err } } diff --git a/internal/validate/validate_test.go b/internal/validate/validate_test.go index 585eca6..827128c 100644 --- a/internal/validate/validate_test.go +++ b/internal/validate/validate_test.go @@ -3,10 +3,38 @@ package validate import ( "strings" "testing" + "time" "github.com/lvcas-dotcom/pgfathom/internal/model" ) +// TestStatementTimeoutMillisNeverDisablesTheCeiling pins the bug this +// function exists to close: Duration.Milliseconds truncates toward zero, and +// 0 means "no limit" to Postgres — the one input (a sub-millisecond timeout) +// meant to fire the most reliably would otherwise silently turn the ceiling +// off instead. +func TestStatementTimeoutMillisNeverDisablesTheCeiling(t *testing.T) { + tests := []struct { + name string + timeout time.Duration + want int64 + }{ + {"one nanosecond rounds up to one millisecond, never zero", time.Nanosecond, 1}, + {"one microsecond rounds up to one millisecond, never zero", time.Microsecond, 1}, + {"exactly one millisecond stays one", time.Millisecond, 1}, + {"whole milliseconds pass through unchanged", 250 * time.Millisecond, 250}, + {"seconds convert exactly", 3 * time.Second, 3000}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := statementTimeoutMillis(tt.timeout); got != tt.want { + t.Errorf("statementTimeoutMillis(%v) = %d, want %d", tt.timeout, got, tt.want) + } + }) + } +} + func val(method model.ValidationMethod, sampled, notNull, distinct, orphanRows, orphanVals int64) model.Validation { return model.Validation{ Method: method, diff --git a/openspec/changes/archive/2026-08-10-corpus-benchmark-harness/.openspec.yaml b/openspec/changes/archive/2026-08-10-corpus-benchmark-harness/.openspec.yaml new file mode 100644 index 0000000..d7bc011 --- /dev/null +++ b/openspec/changes/archive/2026-08-10-corpus-benchmark-harness/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-10 diff --git a/openspec/changes/archive/2026-08-10-corpus-benchmark-harness/REPORT.md b/openspec/changes/archive/2026-08-10-corpus-benchmark-harness/REPORT.md new file mode 100644 index 0000000..8a91922 --- /dev/null +++ b/openspec/changes/archive/2026-08-10-corpus-benchmark-harness/REPORT.md @@ -0,0 +1,92 @@ +# Relatório — benchmark do `pgfathom` contra o corpus de aplicações + +**Data:** 2026-08-10 +**Versão testada:** `fb4ddbd-dirty` +**Corpus:** Redmine, Discourse, Mastodon, Odoo, GitLab (as cinco apps do plano de benchmark de `docs/PGFATHOM.md`, "Corpus de benchmark") + +## Resumo + +| App | Tabelas | FKs declaradas | FKs elegíveis¹ | Recuperadas | Recall | Só nome | Via junção | Falsos positivos | Nota | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---| +| Redmine | 54 | 0 | 0 | 0 | — | 0 | 0 | — | Schema não declara FK nenhuma no Postgres | +| Discourse | 381 | 25 | 25 | 11 | **44,0%** | 11 | 0 | 45 | Povoamento manual (plugins) | +| Mastodon | 115 | 154 | 154 | 20 | **12,99%** | 20 | 0 | 0 | Povoamento manual parcial (núcleo social, ~24/96 tabelas) | +| Odoo | 657 | 2526 | 2526 | 70 | **2,77%** | 56 | 14 | 1 | Demo data oficial, sem povoamento manual | +| GitLab | 1046 | 1846 | 1790² | 29 | **1,62%** | 29 | 0 | 4 | Povoamento manual mínimo (núcleo, tempo limitado) | + +¹ FKs de coluna única — chave composta é `Skipped` pelo próprio `pgfathom` hoje, contar como falha mediria uma limitação já conhecida, não uma regressão (55 puladas no GitLab, 0 nas demais). +² 1846 FKs reais após corrigir duplicação de partição (ver GitLab abaixo) — 1790 elegíveis descontando as 55 compostas. + +CSV agregado (mesmos campos de `metrics.json`, uma linha por app): `dumps_benchmarks/results/summary.csv` (fora do repositório — dado de execução local). + +## Por que os números não são comparáveis entre si + +O fator que mais determina o recall observado **não é** a qualidade do `discover` — é quanto da tabela envolvida em cada FK do gabarito tinha dado de verdade: + +- **Redmine**: 0 FKs declaradas no banco (integridade só em ActiveRecord, nunca em `FOREIGN KEY`). Não existe gabarito pra medir recall — serviu como smoke test do harness, não como dado de recall. +- **Discourse**: gabarito inteiro (25 FKs) vivia em tabelas de plugin que o seed oficial (`dev:populate`) não toca. Povoamento manual cobriu as 25. +- **Mastodon**: gabarito grande (154 FKs, 96 tabelas envolvidas). Por tempo, povoamos só um núcleo social (~24 tabelas). Recall restrito a esse núcleo: **50% (20/40)** — bem mais alto que os 12,99% do total, porque a maior parte do gabarito nunca foi exercitada com dado. +- **Odoo**: único caso com demo data oficial rica o suficiente sem intervenção manual (24 sale orders, 92 stock moves, 61 partners, etc). O recall baixo aqui é o mais "puro" — não é falta de dado, é limite de casamento por nome (ver seção seguinte). +- **GitLab**: maior gabarito do corpus (1846 FKs reais, 1046 tabelas). Por tempo, povoamos manualmente um recorte mínimo (1 grupo, 3 projects, 6 issues, labels, milestones, notes). Recall restrito a esse recorte: **32,4% (11/34)**. + +Ou seja: **Odoo é o número mais confiável pra avaliar a capacidade real do casamento por nome**, porque não tem o viés de "gabarito maior que o dado testado". Discourse e o subconjunto de Mastodon/GitLab mostram recall bem mais alto quando o dado existe. + +## Por que o recall do Odoo (o número "limpo") ainda é baixo — causa raiz + +Investigação no código (`internal/infer`, `internal/profile`) identificou três padrões distintos, cada um com correção diferente: + +1. **Colunas de auditoria de ORM** — 34,8% do gabarito elegível do Odoo (880 de 2526 FKs) é só `write_uid`/`create_uid → res_users`, colunas que o ORM do Odoo adiciona automaticamente em quase toda tabela. Hoje não há tratamento nenhum pra esse padrão (`_by`/`uid` não é sufixo reconhecido em nenhum profile). Padrão comum entre frameworks (Rails `created_by_id`, Django `created_by`), não é peculiaridade só do Odoo. +2. **Tabela com prefixo de módulo** — `partner_id → res_partner`, `company_id → res_company` (Odoo); `target_account_id`/`from_account_id → accounts` (Mastodon). A entidade adivinhada a partir do nome da coluna é *parte* do nome da tabela, não igual a ele. A geração de candidato hoje só casa por **match exato** contra formas conhecidas (nome, prefixo removido, plural/singular) — sem fuzzy nem substring. +3. **Regra de plural faltando** — `favourites.status_id → statuses` (Mastodon) não casa porque o profile `en.toml` não tem regra pra plural em `-uses` (só cobre `-ies`, `-sses`, `-xes`, `-ches`, `-shes`, `-ices`, `-ves`, `-people`, mais o fallback genérico de tirar `s`, que reduz "statuses" a "statuse", não "status"). + +Auto-referência (`parent_id`, `in_reply_to_id` apontando pra própria tabela) **já é suportada** — confirmado no código e em teste dedicado (`TestSelfReferenceIsAllowed`). Não é gap. + +Recomendação de prioridade pra fechar essa lacuna: (1) colunas de auditoria — mais barato, maior retorno; (2) match por sufixo/substring de tabela — resolve a família de prefixo de módulo inteira; (3) regra de plural `-uses`. Uma varredura estatística completa (todo par coluna↔tabela de tipo compatível, sem nome nenhum) resolveria o resto, mas é cara em schema grande (667–1046 tabelas neste corpus) e contraria o princípio do projeto de manter a execução default barata — melhor como modo opt-in, não default. + +## Notas por app + +### Redmine +Seed oficial (`redmine:load_default_data`) carrega só dado de referência (roles, trackers, status), não conteúdo (issues, projetos). Schema sem FK declarada — recall não mensurável, harness validado como smoke test (pipeline completo rodou sem erro, `discover` achou 2 relações reais por conta própria: `workflows.role_id → roles.id`, `workflows.tracker_id → trackers.id`). + +### Discourse +Imagem `discourse/discourse_dev` não tem código-fonte — trocado por `discourse/discourse_test` (imagem de CI oficial). Schema exige extensão `vector` (pgvector). Perfil de nomenclatura trocado pra `en` (schema do corpus inteiro é em inglês, independente do idioma do dado — decisão que vale pras cinco apps). Nenhuma das 25 FKs do gabarito tocava tabela populada pelo seed oficial — povoamento manual via SQL direto, respeitando as FKs reais ainda ativas como validação. + +### Mastodon +Imagem oficial é de produção — `RAILS_ENV=development` quebra (`annotate_rb`/`letter_opener_web` são gems de dev ausentes do bundle). Corrigido para `RAILS_ENV=production`. Precisa de `ACTIVE_RECORD_ENCRYPTION_*` geradas via `db:encryption:init`. `db:setup` só povoa 3 tabelas de config — resto (96 tabelas do gabarito) povoado manualmente só no núcleo social por tempo. + +### Odoo +`-i all` **não instala todos os módulos** apesar do `--help` dizer isso — só instala `base` e dependências diretas (11 de 643 módulos disponíveis). Corrigido com lista explícita de módulos grandes (sale, purchase, stock, account, mrp, project, hr, etc.), que resolveu 123 via dependência transitiva. Único app com demo data rica o suficiente sem povoamento manual. + +### GitLab +O mais espinhoso do corpus. `grafana['enable'] = false` não existe mais nesta versão do omnibus — quebrava o `reconfigure` inteiro. Imagem de produção com a mesma limitação de seed do Discourse/Mastodon — povoamento manual via `gitlab-rails runner` (ActiveRecord, não SQL cru, dado o tanto de cascata via callback do GitLab), o que exigiu descobrir a feature nova de Organizations (`organization_id` obrigatório pra criar usuário/grupo). `-U gitlab` explícito quebra autenticação (role real de peer-auth é `gitlab-psql`). Tabelas particionadas (CI) duplicavam FK por partição no gabarito (2629 brutas → 1846 reais) e quebravam o `DROP CONSTRAINT` — corrigido filtrando por `conislocal`. GitLab embarca Postgres 17, dump não restaura com `postgres:16`. Rodou sozinho, memória apertada na máquina (mínimo ~1 GiB livre) mas sem OOM. + +## Comparação com um GitLab real de produção + +Além do GitLab figurativo do corpus (seed mínimo manual, seção acima), rodamos o `discover` **só leitura** contra um GitLab de produção real (`gitlabhq_production`, Postgres 17.8, autorizado explicitamente pelo usuário para consulta — nenhuma escrita, nenhum `DROP CONSTRAINT`, nenhuma alteração de schema ou dado). + +**Achado que muda a leitura da comparação:** `discover` pula inteiramente qualquer coluna que já faça parte de **qualquer** FK declarada, antes de gerar candidato (`eligible()` em `internal/infer/generate.go`, filtro por coluna, não por par específico). Isso significa que os 22 CONFIRMED abaixo não são "FK que já existia e foi redescoberta" — são relações **genuinamente não declaradas** que esse banco real tem hoje. Por isso não dá pra calcular um "recall" comparável ao do GitLab figurativo (que mede recuperação de FK **removida de propósito**, com gabarito conhecido): aqui nunca removemos nada, então não existe gabarito de comparação — só o que o `discover` acha por cima do que já está catalogado. + +| | GitLab figurativo (benchmark) | GitLab real (produção) | +|---|---|---| +| Tabelas (schema `public`) | 1046 | 1015 | +| Tabelas analisadas pelo `discover` | 1046 | 992 tabelas no escopo · 853 analisadas (86%) | +| FKs declaradas | 1846 (após dedup de partição) | 3030 | +| Dado | seed mínimo manual (1 grupo, 3 projects, 6 issues) | produção real (dezenas de milhares de linhas em tabelas como `p_ci_builds_metadata`, `p_ci_job_artifacts`) | +| Metodologia | FK removida de propósito → `discover` → comparar contra gabarito | leitura pura, nenhuma FK tocada | +| Resultado | recall 1,62% total / 32,4% no recorte povoado (29 e 11 FKs, respectivamente) | **22 relações não-declaradas confirmadas**, 0 broken, 93 weak, 3,546s | +| Puladas | 55 compostas | 139 (25 sem privilégio de leitura, 19 chave composta, 95 particionadas) | +| Fora de escopo | — | 35 referências polimórficas (`noteable_id`+`noteable_type` etc.) — reconhecidas e deliberadamente não analisadas | + +**Leitura:** o GitLab real tem quase o dobro de FK declarada (3030 vs 1846) e uma base de tabelas particionadas bem maior (95 puladas por partição, contra as que o benchmark sintético nem chegou a povoar o suficiente pra testar). As 22 relações CONFIRMED no real são achado genuíno de valor prático — 22 relações que existem nos dados de uma instância de produção, nunca formalizadas como `FOREIGN KEY`, encontradas em 3,5 segundos, sem nenhum falso positivo confirmado (`0 broken`, e nada em CONFIRMED que a validação por dado real não sustentasse). É o cenário mais próximo do uso pretendido do produto: um DBA rodando `discover` contra um banco real, sem gabarito nenhum, só pra ver o que o catálogo está deixando de declarar. + +Pra virar um número de recall comparável de verdade, seria preciso repetir a metodologia destrutiva (remover as 3030 FKs numa cópia restaurada, nunca no banco original) — não autorizado nesta rodada, e não deveria ser, dado que é um banco de produção real. + +## Onde estão os artefatos + +- Harness (scripts): `.claude/benchmark/` — fora do repositório de propósito (scripts de execução local, não código do produto). `.claude/benchmark/RUNBOOK.md` documenta o fluxo completo, o workaround de Docker desta máquina, e todos os gotchas por app em detalhe maior que este relatório. +- Dumps, gabaritos, relatórios brutos (`report.json`, `report_no_probe.json`, `report.txt`, `run.log`, `metrics.json`) e `summary.csv`: `~/Área de trabalho/dumps_benchmarks/` — fora do repositório (dado de terceiro/execução local). +- Documentação da change: `openspec/changes/2026-08-10-corpus-benchmark-harness/` (`proposal.md`, `design.md`, `tasks.md`, este `REPORT.md`) — versionada no repositório. + +## Estado da change + +Tasks 1–3 completas (ambiente, harness, execução das cinco apps). Falta task 4.3: registrar no `README.md` do produto que este número é preliminar — decisão pendente de quando/como publicar, não incluída neste relatório porque é uma edição no README do produto, fora do escopo de "gerar relatório". diff --git a/openspec/changes/archive/2026-08-10-corpus-benchmark-harness/design.md b/openspec/changes/archive/2026-08-10-corpus-benchmark-harness/design.md new file mode 100644 index 0000000..4762ca3 --- /dev/null +++ b/openspec/changes/archive/2026-08-10-corpus-benchmark-harness/design.md @@ -0,0 +1,140 @@ +## Layout + +Decisão revista durante a execução (3.2): os scripts NÃO ficam versionados no +repo principal — vivem em `.claude/benchmark/` (já coberto pelo `.gitignore` +existente do projeto), porque são scripts de execução local, não código do +produto, e o usuário quer poder mexer/documentar neles sem isso entrar em +nenhum commit. `.claude/benchmark/RUNBOOK.md` documenta o fluxo completo pra +retomar em sessão nova. Fora isso, o layout é o mesmo: + +``` +.claude/benchmark/ + apps/ + redmine/docker-compose.yml + gitlab/docker-compose.yml + odoo/docker-compose.yml + discourse/docker-compose.yml + mastodon/docker-compose.yml + seed.sh # sobe a app, roda o seed oficial, espera ficar pronta + ground_truth.sh # extrai FKs declaradas do banco seedado, antes de qualquer dump + dump.sh # pg_dump -Fc + derruba os contêineres da app + run.sh # restaura, remove FKs, roda discover 2x, calcula métricas + compare.py # ground_truth.json + report json (normal e --no-probe) -> metrics.json +``` + +Dado, fora do repo, em `/home/gabriel-arantes/Área de trabalho/dumps_benchmarks/`: + +``` +dumps_benchmarks/ + redmine/ + redmine.dump # pg_dump -Fc, saída do passo "dump" + ground_truth.json # FKs declaradas antes da remoção (o gabarito) + gitlab/ ... + odoo/ ... + discourse/ ... + mastodon/ ... + results/ + redmine/ + report.json # discover --full --format json (probe ligado) + report_no_probe.json # discover --full --format json --no-probe + report.txt # discover --full --format table (leitura humana) + run.log # stdout/stderr + `time -v` de cada execução + metrics.json # ver formato abaixo + gitlab/ ... + odoo/ ... + discourse/ ... + mastodon/ ... + summary.csv # uma linha por app, agregando metrics.json de todos +``` + +**Onde vou olhar para a análise:** `results//metrics.json` por app (o número), `results//report.txt` quando o número for surpreendente (o detalhe — qual relação especificamente não voltou), e `results/summary.csv` para a tabela comparativa final entre os cinco. + +## Seed por aplicação + +Todas rodam sobre Postgres — é a única engine que o `pgfathom` suporta (`docs/PGFATHOM.md`, "PostgreSQL 13+"), então mesmo o Redmine (que também aceita MySQL) sobe com `DB_ADAPTER=postgresql`. + +| App | Imagem | Seed | Observação de porte | +|---|---|---|---| +| Redmine | `redmine:5-alpine` + `postgres:16` | `bundle exec rake redmine:load_default_data REDMINE_LANG=pt-BR` | leve, minutos | +| Discourse | `discourse/discourse_test:release` (imagem de CI oficial, já vem com o repo + gems — `discourse/discourse:release` é produção, não serve; `discourse_dev` não tem o código-fonte) + `pgvector/pgvector:pg15` (schema atual exige a extensão `vector`) | `bin/rails db:create db:migrate && ALLOW_DEV_POPULATE=1 bundle exec rake dev:populate`, mais povoamento manual das tabelas de plugin com FK declarada (ver 3.2 em `tasks.md` — `dev:populate` não cobre nenhuma) | médio | +| Mastodon | `tootsuite/mastodon` + `postgres:16` | `bundle exec rails db:setup` (schema + seeds mínimos; sem gerador de povoamento fictício oficial — se o recall exigir mais volume, complementar com `faker` ad-hoc, a decidir) | médio | +| Odoo | `odoo:17` + `postgres:15` | `-i all` sem `--without-demo` (default já carrega demo pros módulos instalados; `--with-demo=all` não existe na CLI real) | médio, muitos módulos m2m | +| GitLab | `gitlab/gitlab-ce:latest` | `gitlab-rake gitlab:setup` / `db:seed_fu` conforme disponível na imagem omnibus | **pesado** — omnibus sobe Rails, Sidekiq, Redis, Postgres, Gitaly juntos; historicamente pede 4 GB+ de RAM e minutos para health check ficar `healthy` | + +GitLab é o caso que a especificação do produto chama de "melhor caso disponível" (centenas de tabelas), mas também o mais caro de subir — candidato natural a rodar sozinho, sem os outros quatro em paralelo. + +## Gabarito (ground truth) + +Antes de tocar em qualquer FK, `ground_truth.sh` roda contra o banco recém-seedado: + +```sql +select + con.conname, + con.conrelid::regclass::text as child_table, + con.confrelid::regclass::text as parent_table, + array_agg(att.attname order by u.ord) as child_columns, + array_agg(attf.attname order by u.ord) as parent_columns +from pg_constraint con +join lateral unnest(con.conkey) with ordinality as u(attnum, ord) on true +join pg_attribute att on att.attrelid = con.conrelid and att.attnum = u.attnum +join lateral unnest(con.confkey) with ordinality as uf(attnum, ord) on uf.ord = u.ord +join pg_attribute attf on attf.attrelid = con.confrelid and attf.attnum = uf.attnum +where con.contype = 'f' +group by con.conname, con.conrelid, con.confrelid; +``` + +Cada linha vira um registro em `ground_truth.json`, com `is_composite = len(child_columns) > 1`. É esse arquivo — não o schema restaurado — que `run.sh` usa para gerar o `ALTER TABLE ... DROP CONSTRAINT` de cada FK antes de chamar o `discover`. + +## Perfil de nomenclatura usado no `discover` + +`run.sh` roda com `--profile en`, não o `pt-br` default do `pgfathom`. Descoberto na execução real do Discourse (3.2): todo o corpus (Redmine, Discourse, Mastodon, Odoo, GitLab) tem schema em inglês por convenção de framework (Rails/etc.) — o idioma dos dados de seed não muda os nomes de tabela/coluna. Rodar com `pt-br` contra esses schemas usa regras de plural erradas (`categories→category` é regra do inglês, não existe no perfil pt-br) e derruba candidatos por engano. Configurável via `DISCOVER_PROFILE` se algum app do corpus vier a ter schema em outro idioma. + +## Cálculo de recall + +Só FKs de coluna única entram no denominador — chave composta é `Skipped` pelo próprio `pgfathom` hoje (`ReasonCompositePK` / `SkipCompositeKey`), contar como falha seria medir uma limitação já conhecida e documentada, não uma regressão. + +``` +elegíveis = FKs do gabarito com 1 coluna +recuperadas = elegíveis onde (child_table, child_column, parent_table, parent_column) + aparece em report.json com veredito "confirmed" ou "broken" +recall total = recuperadas / elegíveis +recall só-nome = recuperadas em report_no_probe.json / elegíveis +recall via junção = recuperadas em report.json AND NÃO em report_no_probe.json +falsos_positivos = candidatos "confirmed" em report.json sem par no gabarito +``` + +Isso decompõe exatamente como `docs/PGFATHOM.md` pede: "quanto o casamento de nome recupera sozinho e quanto a evidência de uso acrescenta". + +`metrics.json` por app: + +```json +{ + "app": "redmine", + "tables": 0, + "fk_total": 0, + "fk_composite_skipped": 0, + "fk_eligible": 0, + "recovered_total": 0, + "recovered_name_only": 0, + "recovered_via_probe": 0, + "false_positives": 0, + "recall_pct": 0.0, + "duration_normal_s": 0.0, + "duration_no_probe_s": 0.0, + "pgfathom_version": "", + "note": "" +} +``` + +`recall_pct` é `null`, não `0.0`, quando `fk_eligible = 0` — descoberto na execução real do Redmine (3.1): schema sem nenhuma FK declarada no Postgres não é "recall zero", é "sem gabarito pra medir". `note` carrega esse aviso; fica vazio nos casos normais. + +## Isolamento entre seed e benchmark + +Os contêineres da aplicação (Rails/Odoo/GitLab completos) só existem durante o seed — servem para gerar dados realistas, nada mais. Depois do `pg_dump`, `dump.sh` derruba tudo (`docker compose down -v`). O benchmark em si roda contra um `postgres:16` void, restaurado do dump — mais leve, reprodutível sem a stack completa da aplicação de novo, e mais próximo do cenário real do produto (um DBA com acesso só ao Postgres, não à aplicação). + +## Riscos conhecidos antes de executar + +- **Docker não instalado** nesta máquina — precisa de instalação (`apt`, via `sudo`) antes de qualquer passo. Ação que requer confirmação explícita, não é assumida. +- **Memória livre baixa no momento** (~1,5 GiB livres, swap já parcialmente ocupado por outros processos da sessão do usuário — IDE, `ng serve`, um servidor Spring). GitLab sozinho já é pesado; rodar mais de uma app pesada em paralelo é risco real de OOM ou de degradar o resto do trabalho do usuário na máquina. Por isso o harness roda **uma app por vez**, sequencial, com teardown completo entre uma e outra. +- **Mastodon não tem task de povoamento fictício oficial** equivalente às outras quatro — a ser resolvido na hora (schema geralmente já entrega dezenas de tabelas com relações via `db:setup`, mas o volume de linhas pode ficar baixo; se o recall não for representativo, complementar dados sintéticos fica registrado como decisão tomada durante a execução, não escondida no resultado). +- **GitLab omnibus** é a imagem mais difícil de automatizar de forma decisiva sem tentativa — health check e task exata de seed variam por versão; primeira tentativa pode exigir ajuste depois de ver o log real. diff --git a/openspec/changes/archive/2026-08-10-corpus-benchmark-harness/proposal.md b/openspec/changes/archive/2026-08-10-corpus-benchmark-harness/proposal.md new file mode 100644 index 0000000..aecf15b --- /dev/null +++ b/openspec/changes/archive/2026-08-10-corpus-benchmark-harness/proposal.md @@ -0,0 +1,28 @@ +## Why + +O README classifica o projeto como `EARLY ALPHA` até existir uma taxa de recuperação medida em schema real e reproduzível — a Fase 8 do roadmap (`composite-keys-and-benchmark`) descreve exatamente esse corpus: GitLab, Odoo, Discourse, Redmine e Mastodon, com o procedimento "carregar o schema, remover todas as FKs declaradas, rodar o `discover`, medir quantas voltaram e quantos falsos positivos apareceram" (`docs/PGFATHOM.md`, "Corpus de benchmark"). + +Essa change entrega só a metade "corpus + harness" da Fase 8, adiantada. As chaves compostas (`internal/infer.SkipCompositeKey`) continuam fora de escopo — não estão implementadas ainda — então o número que este harness produz **não é** o número final publicável no README. É um baseline de engenharia: mede o que o `discover` já recupera hoje, decompõe entre casamento de nome e evidência de junção, e vira o piso de comparação de quando as chaves compostas entrarem. + +## What Changes + +- Scripts de infraestrutura (fora do binário `pgfathom`, sem nova dependência em `go.mod`) para, por aplicação do corpus: + 1. Subir a aplicação via Docker com seu Postgres interno e popular dados via o mecanismo oficial dela (rake/seed/demo flag). + 2. Extrair o "gabarito": todas as FKs declaradas (`pg_constraint` `contype='f'`), separando as de coluna única (o que o `discover` de hoje consegue recuperar) das compostas (fora de alcance até chaves compostas existirem). + 3. Exportar o dump (`pg_dump -Fc`) e descartar os contêineres pesados da aplicação. + 4. Restaurar o dump num Postgres limpo e descartável, remover todas as FKs declaradas, rodar `pgfathom discover --full` duas vezes (uma normal, uma com `--no-probe`) para decompor nome vs. junção, e comparar o resultado contra o gabarito. + 5. Calcular recall, falsos positivos e tempo de execução, e gravar tudo em disco. +- Nenhuma linha de `internal/*` ou `cmd/*` muda. O harness só invoca o binário já existente via `discover --format json`. +- Dumps, gabaritos e resultados ficam fora do repositório, em `/home/gabriel-arantes/Área de trabalho/dumps_benchmarks/`, porque são artefatos de dados de terceiros (ainda que fictícios/demo) e de execução local, não código-fonte. +- Decisão revista durante a execução (3.2): os próprios scripts do harness também ficam fora do repositório principal, em `.claude/benchmark/` (gitignorado) — são ferramentas de execução local, não parte do produto `pgfathom`. Só esta documentação da change (`proposal.md`/`design.md`/`tasks.md`) é versionada. + +## Capabilities + +Nenhuma. Este change não adiciona nem modifica comportamento público do `pgfathom` — ele exercita o `discover` já existente como caixa-preta, pela CLI. Não há `specs/` novo. + +## Impact + +- Sem dependência nova no `go.mod`: os scripts usam `docker`, `psql`/`pg_dump`/`pg_restore` e `jq`, fora da árvore de build do Go. +- Regra de read-only do produto (`openspec/project.md`, regra 1) permanece intacta: o `pgfathom` nunca escreve no banco analisado. Quem remove as FKs do gabarito é o script do harness, com `psql` direto, fora da ferramenta — é o próprio harness preparando a fixture, não o produto mutando um banco de produção. +- Regra 2 (dado do usuário nunca sai): os bancos do corpus são de demonstração/seed fictício das próprias aplicações, não dado real de terceiro. Ainda assim os dumps não entram no repositório nem em nenhum output versionado — ficam só em `dumps_benchmarks/`, fora do controle de versão. +- Bloqueadores de ambiente identificados antes de qualquer execução (ver mensagem de acompanhamento no chat): Docker não está instalado nesta máquina, e a memória livre no momento da checagem é baixa (~1,5 GiB livres de 30 GiB, swap já em 6/8 GiB). Isso condiciona sequenciamento e possivelmente instalação de pacote via sudo — decisão do usuário, não autônoma. diff --git a/openspec/changes/archive/2026-08-10-corpus-benchmark-harness/tasks.md b/openspec/changes/archive/2026-08-10-corpus-benchmark-harness/tasks.md new file mode 100644 index 0000000..d1292cf --- /dev/null +++ b/openspec/changes/archive/2026-08-10-corpus-benchmark-harness/tasks.md @@ -0,0 +1,45 @@ +## 1. Ambiente + +- [x] 1.1 Confirmar com o usuário: instalar Docker (requer `sudo`) — não assumir. Confirmado: instalar via apt. +- [x] 1.2 Confirmar sequenciamento uma app por vez, dada a memória livre baixa no momento da checagem. Confirmado: uma por vez, mais leve primeiro (Redmine → Discourse → Mastodon → Odoo → GitLab). +- [x] 1.3 Criar `/home/gabriel-arantes/Área de trabalho/dumps_benchmarks/` e `dumps_benchmarks/results/` + +## 2. Harness (scripts, versionados em `benchmark/`) + +- [x] 2.1 `apps//docker-compose.yml` para Redmine, Discourse, Mastodon, Odoo, GitLab — todos sobre Postgres. Redmine testado só na próxima seção; Discourse/Mastodon/GitLab são rascunhos não exercitados (ver comentários nos próprios `docker-compose.yml`) — Discourse usa `discourse/discourse_dev`, não a imagem de produção citada informalmente antes, por não existir caminho simples de "app + postgres" pra imagem oficial. Odoo corrigido: `--with-demo=all` não existe na CLI real, default já carrega demo para os módulos de `-i`. +- [x] 2.2 `seed.sh `: sobe a app (`docker compose up -d --wait`), roda o comando de seed de `apps//env` +- [x] 2.3 `ground_truth.sh `: roda a query de `pg_constraint` e grava `ground_truth.json`, com `is_composite` marcado +- [x] 2.4 `dump.sh `: `pg_dump -Fc` para `dumps_benchmarks//.dump`, depois `docker compose down -v` na stack da app +- [x] 2.5 `run.sh `: sobe `postgres:16` void, `pg_restore`, `ALTER TABLE ... DROP CONSTRAINT` de cada FK do gabarito, roda `pgfathom discover --full --schema public --format json` e de novo com `--no-probe`, grava `report.json`, `report_no_probe.json`, `report.txt`, `run.log` +- [x] 2.6 `compare.py`: lê gabarito + os dois report.json, calcula `metrics.json` conforme fórmula do `design.md` +- [x] 2.7 Um alvo `make benchmark-` no `Makefile` chamando os quatro passos em sequência + +## 3. Execução (uma app por vez, só após 1.1/1.2 confirmados) + +- [x] 3.1 Redmine — ponta a ponta. Pipeline validado (seed → ground_truth → dump → run → compare), mas achado importante: o schema do Redmine declara **zero FKs no Postgres** (54 tabelas, `pg_constraint` vazio — integridade só na camada ActiveRecord), contrariando a premissa de `docs/PGFATHOM.md` de que é um schema "completamente anotado com FK". `recall_pct`/`false_positives` saem `null` com `note` explicando, não `0` — decisão tomada com o usuário. Redmine não entra na tabela de recall final; serviu para validar a mecânica do harness antes das apps seguintes. +- [x] 3.2 Discourse — ponta a ponta, com três descobertas na execução real: + 1. `discourse/discourse_dev` (imagem original do compose) não vem com o código-fonte — trocado para `discourse/discourse_test` (imagem de CI oficial, já com repo + gems instalados em `/var/www/discourse`). `docker-compose.yml` documenta isso. + 2. Schema atual do Discourse usa a extensão `vector` (pgvector) — `db` do compose e `RUNNER_PG_IMAGE` do `run.sh` viraram `pgvector/pgvector:pg15`/`pg16`. `run.sh` agora aceita `RUNNER_PG_IMAGE` por app (default `postgres:16`). + 3. Perfil de nomenclatura: todo o corpus tem schema em inglês (convenção de framework), independente do idioma do dado — `run.sh` passa `--profile en` por padrão agora (configurável via `DISCOVER_PROFILE`), não o `pt-br` default do `pgfathom`. + 4. `dev:populate` (seed oficial) não popula nenhuma das 25 tabelas com FK declarada (todas de plugin: `ad_plugin_*`, `poll_*`, `ai_tool_actions`, `reviewable_notes`, `discourse_staff_alias_*`, `javascript_caches`, `user_security_keys`, `optimized_videos`, `user_profiles`) — recall inicial saía 0% não por limitação do `discover`, mas por ausência de dado. Populado manualmente via SQL direto (3 linhas com valores distintos por tabela, FKs reais ainda ativas validando a inserção) antes do dump final. Resultado real: **recall 44% (11/25)**. Os outros 14 continuam ausentes mesmo com dado variado — confirmado que é limite da geração de candidato por nome (`user_id`/`post_id`/`group_id`/`category_id` em algumas tabelas, e nomes com prefixo como `profile_background_upload_id` que a heurística de sufixo não decompõe), não falta de dado. Vale abrir como limitação conhecida do `discover`, não como bug do harness. +- [x] 3.3 Mastodon — ponta a ponta. `db:setup` sozinho não basta (confirmado: só carrega `accounts`/`oauth_applications`/`user_roles`, 93 de 96 tabelas do gabarito ficam vazias). Descobertas na execução real: + 1. Imagem oficial `tootsuite/mastodon` é de produção — rodar com `RAILS_ENV=development` quebra em `NameError`/`LoadError` (gems do grupo `:development` como `annotate_rb`, `letter_opener_web` não estão no bundle, mas código checa `Rails.env.development?` sem hatch de escape em vários pontos). Corrigido: `RAILS_ENV=production` — `database.yml` usa as mesmas variáveis `DB_*`, não muda nada além disso. + 2. Precisa de `ACTIVE_RECORD_ENCRYPTION_*` geradas via `bin/rails db:encryption:init` antes de qualquer comando de banco. + 3. Escopo de povoamento: 154 FKs declaradas (bem mais que o Discourse), 96 tabelas envolvidas. Decidido com o usuário povoar só o núcleo social (~24 tabelas: accounts, users, statuses, follows, favourites, mentions, media_attachments, tags, statuses_tags, notifications, bookmarks, lists, list_accounts, polls, poll_votes, custom_emojis, blocks, mutes, account_stats, status_stats, conversations, featured_tags, follow_requests, markers), não as 96 exaustivamente — as ~70 tabelas de nicho/admin (`fasp_*`, `webauthn_credentials`, `bulk_import*`, relatórios anuais, etc) ficam em 0 linhas, não medidas. + 4. Resultado: **recall 12,99% (20/154)** sobre o gabarito total; **50% (20/40)** restrito às FKs com as duas pontas no subconjunto povoado — mesma lacuna de geração de candidato por nome do Discourse (`target_account_id`, `status_id`, `in_reply_to_id`, `follow_id` nunca viram candidato mesmo com dado real e variado). `false_positives: 0`. +- [x] 3.4 Odoo — ponta a ponta, sem precisar de povoamento manual (demo data é rico de verdade: 24 sale orders, 92 stock moves, 61 partners, etc). Duas descobertas: + 1. `-i all` **não instala todos os módulos**, apesar do `--help` documentar isso — só instala `base` e suas 10 dependências diretas, mesmo repetindo o comando numa base com a lista de módulos já populada (643 módulos disponíveis na imagem, só 11 instalados). Corrigido: lista explícita dos módulos grandes/relacionais (`sale,purchase,stock,account,mrp,project,hr,point_of_sale,website,crm,mail,calendar,contacts,fleet,maintenance,repair,sale_management,stock_landed_costs`), que resolve 123 módulos via dependência transitiva. + 2. Schema gigante: 667 tabelas, **2526 FKs declaradas**. Recall: **2,77% (70/2526)**, `false_positives: 1`, `recovered_via_probe: 14` (primeira vez no corpus que a mineração de junção contribui algo — Odoo tem views/functions reais no schema). Recall baixo aqui não é falta de dado (já é rico) — é a convenção de nomenclatura do Odoo colidindo com o casamento por nome: **34,8% do gabarito elegível (880/2526) é só o padrão `write_uid`/`create_uid` → `res_users`**, colunas de auditoria que o ORM do Odoo adiciona automaticamente em quase toda tabela e cujo nome não guarda relação nenhuma com `res_users`. Resto majoritariamente `_id → res_`/`_` (`partner_id → res_partner`, `company_id → res_company`) — nome da coluna não carrega o prefixo do módulo/tabela real. +- [x] 3.5 GitLab — sozinho, memória apertada no momento (6,4Gi disponível, confirmado com o usuário antes de tentar). Várias descobertas na execução real: + 1. `GITLAB_OMNIBUS_CONFIG` reduzido pra economizar memória (puma sem workers, sidekiq concorrência 5, registry/pages desligados) — `grafana['enable'] = false` **não existe mais** nesta versão do omnibus e quebra o `reconfigure` inteiro (`Mixlib::Config::UnknownConfigOptionError`); removido. + 2. `db:seed_fu` na imagem de produção só roda os fixtures de produção (settings, organização default, admin) — mesma limitação de imagem-de-produção do Discourse/Mastodon. Populado manualmente via `gitlab-rails runner` (não SQL cru — GitLab cascade demais via `Projects::CreateService`/callbacks): 1 grupo, 3 projects, 3 users, 6 issues, 3 labels, 3 milestones, 6 notes. Descoberta no caminho: `Users::CreateService`/`Group.create!` exigem `organization_id` explícito (feature de Organizations, nova) — sem isso, "Organization can't be blank". + 3. `gitlab-psql`/`pg_dump` com `-U gitlab` explícito quebra com "Peer authentication failed" — o role real de peer-auth do omnibus é `gitlab-psql` (não `gitlab`), só o wrapper `gitlab-psql` troca de usuário do SO via `chpst` antes de conectar. `PG_DUMP_BIN` replica esse `chpst` manualmente; `PSQL_CONN_ARGS`/`PG_DUMP_CONN_ARGS` novos em `env`, sem `-U`. Achado um bug de bash no caminho: `${VAR:-default}` trata string vazia como não-setada — `dump.sh` corrigido pra `${VAR-default}`. + 4. Schema usa tabelas particionadas (CI) — a mesma FK aparece uma vez por partição em `pg_constraint` (`conislocal=false` nas cópias herdadas), e Postgres recusa `DROP CONSTRAINT` direto numa herdada. `ground_truth.sh` e o passo de DROP do `run.sh` corrigidos pra filtrar só `conislocal=true` — sem isso, `fk_total` saía inflado (2629 vs 1846 reais) e o `run.sh` quebrava tentando dropar uma herdada. + 5. GitLab embarca **Postgres 17** — o dump sai em "Dump Version 1.16", que o `pg_restore` do `postgres:16` não reconhece. `RUNNER_PG_IMAGE=postgres:17` no `env`. + 6. Resultado: **recall 1,62% (29/1790)** no gabarito total (1046 tabelas, 1846 FKs, 55 compostas puladas); **32,4% (11/34)** restrito ao pequeno subconjunto povoado. `false_positives: 4`. Número baixo é esperado — povoamos só um recorte mínimo (tempo) de um schema com 1046 tabelas. + +## 4. Consolidação + +- [x] 4.1 `results/summary.csv` agregando os cinco `metrics.json` +- [x] 4.2 Reportado ao usuário no chat, app por app, durante a execução (2026-08-10) — recall total e decomposto, falsos positivos, tempo, skip de chave composta. Consolidado em `REPORT.md` nesta pasta, incluindo a causa-raiz do recall baixo (colunas de auditoria de ORM, prefixo de módulo, regra de plural faltando — investigação de código, sem implementação). +- [ ] 4.3 Registrar no `README.md` que este número é preliminar (sem chaves compostas ainda) — não substituir a tabela final da Fase 8 diff --git a/openspec/changes/archive/2026-08-12-audit-interactive-key-resolution/.openspec.yaml b/openspec/changes/archive/2026-08-12-audit-interactive-key-resolution/.openspec.yaml new file mode 100644 index 0000000..5081c98 --- /dev/null +++ b/openspec/changes/archive/2026-08-12-audit-interactive-key-resolution/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-12 diff --git a/openspec/changes/archive/2026-08-12-audit-interactive-key-resolution/design.md b/openspec/changes/archive/2026-08-12-audit-interactive-key-resolution/design.md new file mode 100644 index 0000000..136600a --- /dev/null +++ b/openspec/changes/archive/2026-08-12-audit-interactive-key-resolution/design.md @@ -0,0 +1,59 @@ +## Context + +`internal/validate` continua a única camada que lê dado de tabela do usuário — esta change não move essa fronteira, só lhe dá um segundo chamador dentro de `audit` (o primeiro veio da change anterior). A pergunta que já governava a fase 2 e a change de eficiência continua valendo: a ferramenta será apontada para o banco de produção de outra pessoa, e um prompt que trava um script em CI é tão ruim quanto uma query que trava o servidor. + +## Goals / Non-Goals + +**Goals** + +Testar, como candidato de chave, a combinação de colunas que a heurística de catálogo de hoje não vê — as FKs de coluna única de uma tabela-ponte sem índice sobre o par. Oferecer coluna sintética como saída honesta quando não há chave natural, nomeada pela convenção que o próprio schema já declara em milhares de FKs, sem forçar quem revisa a inventar um nome. Perguntar uma vez por execução, não uma vez por tabela — a decisão é sobre o cenário do schema inteiro, e uma auditoria com centenas de tabelas sem PK não pode virar centenas de perguntas iguais. Fazer os dois sem exigir uma flag nova: o comportamento nasce do ambiente (terminal interativo), não de o usuário lembrar de pedir. + +**Non-Goals** + +Não perguntar o nome da coluna sintética. O nome é sempre o que o schema já usa — a mesma convenção que qualquer outra tabela do schema segue — nunca texto livre digitado no prompt; se não há convenção nenhuma para seguir, não há coluna sintética a oferecer, só a chave composta (quando existir) ou pular. Não perguntar tabela a tabela: a pergunta é uma só, feita depois que todo o catálogo já foi avaliado, e a resposta vale para todas as tabelas pendentes de uma vez. Não persistir estado entre execuções — não há "continuar de onde parou" entre pausas porque não há mais de uma pausa. Não sondar toda combinação de coluna possível interativamente: a única combinação nova oferecida é a das FKs de coluna única da própria tabela, porque essa é a que tem uma razão de catálogo para ser tentada; qualquer outra o usuário só alcança pelo caminho de coluna sintética. Não tornar `discover` interativo — o escopo é só `audit`, e só o caminho de chave ausente. + +## Decisions + +### O gate é TTY em ambas as pontas, nunca uma flag + +`Streams.Interactive` é `true` só quando stdin e stdout são ambos um terminal real (`isTerminal`, já existente em `internal/cli/output.go`, sem dependência nova). As duas pontas importam: stdin sozinho não basta porque `--out` redireciona artefatos mas o terminal continua no stdout do resumo; stdout sozinho não basta porque um pipe no stdin (`echo | pgfathom audit`) não tem ninguém para responder. Isso é decidido uma vez, na construção de `Streams`, e nunca depende de uma flag — é assim que "comportamento do fluxo, não algo que o usuário precisa lembrar de ligar" fica garantido: rodar em CI, com saída redirecionada ou `--format json > file`, nunca aciona um prompt, porque `Interactive` já é falso antes de qualquer achado ser avaliado. + +`--no-probe-keys` desliga o caminho inteiro mesmo em terminal interativo — pedir para não ler dado nenhum inclui não pausar perguntando sobre dado. + +### A decisão é global: uma pergunta, não uma por tabela + +`resolveUnconfirmedKeys` primeiro percorre todo o catálogo e junta toda tabela cujo achado de PK ausente `probeMissingKeys` deixou sem chave confirmada — nenhuma pergunta acontece nesse laço. Só depois de ter o cenário inteiro é que o comando fala com o operador: quantas tabelas estão pendentes, quantas têm candidato composto (FKs de coluna única não testadas), e o que a convenção de nome de PK do schema diz — cada número citando os exemplos concretos que o sustentam (`NamingEvidence.Examples`, ver adendo abaixo), não só uma porcentagem. A pergunta em si é uma só, e a resposta se aplica a todas as tabelas pendentes de uma vez: `[a]` roda `validate.ProbeUniqueness` no candidato composto de cada tabela que tiver um; `[b]` aplica a sugestão de coluna sintética, com o mesmo nome, a todas; `[enter]` não muda nada. Isso é diferente da primeira versão desta change, que pausava tabela a tabela — corrigido depois da revisão do usuário: numa auditoria com centenas de tabelas sem PK, a mesma pergunta repetida centenas de vezes é fadiga, não ajuda, e o nome da coluna sintética nunca deveria ter sido texto livre — o schema já diz qual é. + +### O nome da coluna sintética nunca é digitado + +`resolvePKName` devolve o topo do ranking que `Profile.Detect` já produz (`detection.PrimaryKeyNames[0].Affix`), sem limiar de confiança: seja qual for o nome mais comum, é o que o resto do schema já usa, e não há uma "escolha melhor" para o operador fazer sobre isso — só uma resposta a "usar esse nome, ou não". Se a detecção não achou nome nenhum (schema sem tabela de PK simples suficiente para tabular, ver `minPKNameCount`/`minPKNameShare` em `internal/profile`), a opção `[b]` simplesmente não aparece no menu — não há convenção nenhuma para seguir, e inventar uma seria exatamente a hipótese sem evidência que a regra 5 proíbe. + +### O candidato composto novo são as FKs de coluna única, não força bruta + +`candidateKeys` (change anterior) já cobre "colunas de um índice não-único existente". O gap que esta change fecha é diferente: uma tabela de associação cujas duas FKs (`idkey_a`, `idkey_b`) não têm índice nenhum sobre o par — o motivo mais comum de ausência de PK numa tabela-ponte de schema legado. O candidato novo é "todas as colunas de FK de coluna única declaradas na tabela", oferecido só quando são duas ou mais e ainda não fazem parte de nenhum candidato já testado pelo caminho automático. Continua sendo só um nome de candidato: a confirmação é sempre `validate.ProbeUniqueness`, contagem completa, nunca afirmada sem prova — a regra 5 não abre exceção para o caminho interativo. + +### Resposta não reconhecida pergunta de novo, nunca vira pular por engano + +`promptKeyResolution` recontrói o prompt e lê de novo sempre que a resposta não bate com nenhuma opção disponível — imprime "invalid answer" e tenta outra vez, em vez de tratar qualquer coisa não reconhecida como `[enter]`. Um `[enter]` de verdade (linha vazia) é sempre válido e sempre significa pular; a diferença entre "o operador quis pular" e "o operador digitou errado" importa, e só a primeira deveria produzir esse resultado. Isso inclui o EOF de stdin fechado: `bufio.Reader.ReadString` devolve linha vazia junto com `io.EOF`, que já bate na regra de linha vazia — não precisa de tratamento especial além de não tentar ler de novo depois. + +### Exemplos concretos por trás de cada convenção citada (adendo) + +Toda vez que o `audit` ou o `discover` citam uma convenção detectada — nome de PK, prefixo de referência, prefixo de tabela — a citação passa a incluir de 1 a `model.MaxNamingExamples` (3) objetos reais que a sustentam, não só a contagem e a porcentagem. `internal/profile/detect.go` acumula os exemplos durante a mesma passada que já conta ocorrências, capando na acumulação (um `namingAccumulator` por candidato) em vez de recortar depois — um schema com milhares de tabelas nunca guarda mais que um punhado de strings curtas por convenção. A motivação é a mesma de todo achado do `audit`: uma afirmação sem como ser checada contra o schema real não é melhor que um palpite. + +### Coluna sintética é seu próprio artefato de duas etapas + +`writeConfirmedPrimaryKey` (change anterior) já usa o padrão de duas etapas — `CREATE UNIQUE INDEX CONCURRENTLY`, depois `ADD PRIMARY KEY USING INDEX` — para não pagar o lock de `ADD PRIMARY KEY` direto. A coluna sintética herda o padrão, com uma ressalva honesta a mais: `ADD COLUMN ... GENERATED ALWAYS AS IDENTITY` já reescreve a tabela para popular a sequência em toda linha existente — não há como evitar esse custo, ele é do ato de criar a coluna, não da promoção a PK. O comentário do artefato diz isso, em vez de sugerir que o caminho de duas etapas o evita. + +## Risks / Trade-offs + +- **Um script que herda um terminal interativo por engano** (ex.: `pgfathom audit` chamado de dentro de outro programa que aloca um pty) pausaria sem que ninguém leia o prompt. Mitigado pelo mesmo padrão que `resolveColor` já usa (`TERM=dumb` também desliga cor); não é um risco novo desta change, é o risco que qualquer detecção de TTY carrega, e o padrão já é aceito no projeto. +- **A mesma resposta se aplica a tabelas bem diferentes entre si** — uma tabela-ponte de duas linhas e uma de dois milhões, ambas resolvidas por `[a]` ou `[b]` sem distinção. Aceito porque a alternativa (perguntar por tabela) é o problema que esta versão corrigiu; o operador que quiser tratamento diferente por tabela ainda tem `--no-probe-keys` mais uma edição manual do achado catálogo-only como saída. +- **Coluna sintética escolhida pela convenção do schema pode colidir** com uma coluna existente numa tabela específica. O `audit` não valida o nome contra o catálogo de cada tabela antes de sugerir; a colisão só aparece quando o `.sql` gerado falhar ao rodar naquela tabela, porque a ferramenta nunca executa DDL — o mesmo raciocínio de "artefato revisável, nunca executado" que já cobre todo o resto do produto. + +## Migration + +Nenhuma. Comportamento aditivo: sem terminal interativo, ou com `--no-probe-keys`, a saída é byte a byte a mesma da change anterior. `schema_version` não muda — ver proposal.md. + +## Open Questions + +Nenhuma pendente nesta revisão — o limiar de confiança que ainda estava em aberto (`autoApplyPKNameShare`) saiu do desenho junto com o auto-apply silencioso. diff --git a/openspec/changes/archive/2026-08-12-audit-interactive-key-resolution/proposal.md b/openspec/changes/archive/2026-08-12-audit-interactive-key-resolution/proposal.md new file mode 100644 index 0000000..75f9780 --- /dev/null +++ b/openspec/changes/archive/2026-08-12-audit-interactive-key-resolution/proposal.md @@ -0,0 +1,33 @@ +## Why + +A change `2026-08-12-efficiency-audit` deu ao `audit` um caminho de sondagem para tabelas sem PK: promove `UNIQUE NOT NULL` quando existe, e tenta confirmar por contagem completa uma chave candidata quando não existe. Quando nem a promoção nem a sondagem confirmam nada — sem candidato plausível, ou candidato testado e rejeitado — o achado fica sem sugestão acionável. Quem revisa o relatório não tem caminho: precisa descobrir sozinho se a tabela tem uma chave real escondida numa combinação de colunas que a heurística de catálogo não tentou, ou se o caminho certo é criar uma coluna sintética. + +Duas lacunas concretas do estado atual, ambas visíveis num audit real contra schema de gestão pública: + +1. **Tabela-ponte sem índice.** `candidateKeys` só considera colunas de um índice não-único já existente. Uma tabela de associação com duas FKs de coluna única (`idkey_a`, `idkey_b`) e nenhum índice sobre o par nunca é testada — é exatamente o caso mais comum de tabela sem PK em schema legado, e a heurística atual não olha para lá. +2. **Nenhuma chave real existe.** Algumas tabelas legítimas não têm nem podem ter uma chave natural (log de auditoria, staging, tabela de carga). A única saída honesta é uma coluna sintética — e o schema já declara, nas milhares de FKs existentes, que convenção usa para nomear chave primária (`idkey`, `id`, etc.). Ninguém pediu essa informação ao schema ainda. + +Esta change fecha as duas, como parte do fluxo padrão do `audit` — sem flag nova. Quando o terminal é interativo, toda tabela que chegar ao fim do caminho automático sem chave confirmada é resolvida **de uma vez, ao final da execução**: o comando relata quantas tabelas estão sem chave, quantas têm um candidato composto ainda não testado (as FKs de coluna única da própria tabela) e o que o schema já diz sobre como nomear uma chave primária, e pergunta uma única vez o que fazer — recomendar a chave composta onde houver candidato, recomendar uma coluna sintética nomeada pela convenção do próprio schema (nunca digitada — segue o mesmo padrão que qualquer outra tabela já usa), ou pular. Uma resposta não reconhecida é reportada como inválida e perguntada de novo, nunca tratada como "pular" por engano. Fora de terminal interativo, ou com `--no-probe-keys`, nada disso roda: o comportamento de hoje (achado sem sugestão) é preservado sem exceção. + +## What Changes + +- `internal/profile`: `Detect` passa a tabular o nome literal da coluna de PK em toda tabela de PK de coluna única, com os mesmos limiares de proporção que já regem prefixo/sufixo de referência. `NamingDetection` ganha `PrimaryKeyNames` e `SinglePKTables`. Cada `NamingEvidence` (as quatro convenções detectadas) passa a carregar `Examples` — poucos objetos concretos que sustentam a convenção, para que a citação seja verificável, não só uma porcentagem. +- `internal/model`: novo `SuggestionKind` — `synthesize_primary_key` — para a sugestão de coluna sintética. Reaproveita `Suggestion.Columns` para o nome da coluna nova e `Suggestion.Note` para a proveniência (convenção detectada, com exemplos). +- `internal/cli`: `Streams` ganha o campo `Interactive`, decidido por `StdStreams` a partir de stdin **e** stdout serem terminais reais — nunca de uma flag, para que o comportamento siga o ambiente de execução em vez de precisar ser lembrado. `audit` ganha `--profile` (mesma flag e mesmo default de `discover`), calcula a detecção de convenção a partir do catálogo já lido (nenhuma query nova), e, só quando `Interactive` e a sondagem de chave está ligada, junta toda tabela cujo achado de PK ausente não confirmou nada, mostra o resumo do cenário e pergunta uma única vez — a resposta se aplica a todas de uma vez, não tabela a tabela. +- `internal/report`: terminal, a seção DETECTED do `discover` e `suggested_keys.sql` passam a renderizar `synthesize_primary_key` e os exemplos por trás de cada convenção citada — DDL de duas etapas (criar a coluna, então promover), o mesmo padrão de lock já usado para chave confirmada por sondagem. + +## Capabilities + +### Modified Capabilities + +- `structural-audit`: ganha a resolução interativa de chave ausente — convenção de nomenclatura de PK detectada do catálogo, candidato de chave composta a partir de FKs de coluna única não cobertas pela heurística existente, e a sugestão de coluna sintética. + +### New Capabilities + +Nenhuma. Estende `structural-audit`, que já cobre PK ausente desde a change anterior. + +## Impact + +`internal/cli` passa a ler stdin no meio da execução do `audit` — primeiro lugar do produto que faz isso. Gated por TTY em ambas as pontas (stdin e stdout), nunca por flag: um pipe, redirecionamento ou CI nunca vê um prompt, porque `Interactive` já sai falso antes de qualquer leitura ser tentada. `internal/profile` passa a ser dependência de `audit`, não só de `discover`. Nenhuma dependência nova no binário — a detecção de TTY já existe em `internal/cli/output.go` (`isTerminal`), sem biblioteca externa. + +`schema_version` não incrementa: `Suggestion` já é aditivo desde a change anterior, e `synthesize_primary_key` é só um novo valor de um campo `string` já existente — o mesmo raciocínio que não incrementou a versão quando novos `FindingKind` foram adicionados. diff --git a/openspec/changes/archive/2026-08-12-audit-interactive-key-resolution/specs/structural-audit/spec.md b/openspec/changes/archive/2026-08-12-audit-interactive-key-resolution/specs/structural-audit/spec.md new file mode 100644 index 0000000..4a9e864 --- /dev/null +++ b/openspec/changes/archive/2026-08-12-audit-interactive-key-resolution/specs/structural-audit/spec.md @@ -0,0 +1,70 @@ +## ADDED Requirements + +### Requirement: A resolução interativa de chave ausente é gated por terminal, nunca por flag + +O comando SHALL só oferecer resolução interativa de chave ausente quando stdin e stdout forem ambos um terminal interativo. O comando SHALL NOT introduzir uma flag para ligar ou desligar esse comportamento por si só — ele segue o ambiente de execução. `--no-probe-keys` SHALL desligar a resolução interativa junto com a sondagem automática, porque ambas leem dado da tabela. + +#### Scenario: Saída redirecionada nunca pausa + +- **WHEN** o comando roda com stdout redirecionado para um arquivo ou pipe +- **THEN** nenhuma tabela sem chave confirmada dispara um prompt, e a saída é idêntica à que o comando produziria sem esta capability + +#### Scenario: --no-probe-keys desliga tudo + +- **WHEN** o comando roda com `--no-probe-keys`, mesmo em terminal interativo +- **THEN** nenhuma leitura de dado acontece, incluindo a resolução interativa + +### Requirement: A resolução de chave ausente é uma decisão única por execução, não por tabela + +Quando houver ao menos uma tabela sem chave confirmada e o terminal for interativo, o comando SHALL primeiro avaliar todo o catálogo e só então relatar, uma única vez, quantas tabelas estão pendentes, quantas têm um candidato composto ainda não testado, e o nome de primary key mais comum entre as tabelas do escopo que já têm uma — cada convenção citada acompanhada de exemplos concretos dos objetos que a sustentam. O comando SHALL perguntar no máximo uma vez por execução o que fazer, e a resposta SHALL se aplicar a todas as tabelas pendentes de uma vez, nunca tabela a tabela. + +#### Scenario: Resumo antes da pergunta + +- **WHEN** três tabelas ficam sem chave confirmada, duas delas com candidato composto +- **THEN** o comando relata as três tabelas, os dois candidatos compostos, e a convenção de nome de PK — antes de perguntar qualquer coisa + +### Requirement: A coluna sintética é sempre nomeada pela convenção do schema, nunca digitada + +Quando o operador escolher a recomendação de coluna sintética, o comando SHALL nomeá-la com o nome de primary key mais comum entre as tabelas do escopo que já têm uma chave de coluna única — o mesmo nome que qualquer outra tabela do schema já usa. O comando SHALL NOT aceitar um nome de coluna digitado livremente. Quando nenhum nome puder ser determinado a partir do schema, a opção de coluna sintética SHALL NOT ser oferecida. + +#### Scenario: Coluna sintética segue a convenção + +- **WHEN** o operador escolhe a recomendação de coluna sintética e o schema majoritariamente nomeia a chave primária de um jeito +- **THEN** toda tabela pendente resolvida por essa escolha ganha uma coluna sintética com esse nome, sem sondagem de dado + +#### Scenario: Sem convenção, sem opção de coluna sintética + +- **WHEN** nenhuma tabela do escopo tem chave primária de coluna única o suficiente para tabular uma convenção +- **THEN** o menu não oferece a recomendação de coluna sintética, só o candidato composto (quando existir) e pular + +### Requirement: Composto e sintético são recomendações globais, aplicadas a toda tabela pendente + +Ao escolher a recomendação de chave composta, o comando SHALL testar, por contagem completa, o candidato de cada tabela pendente que tiver um — nunca afirmado sem essa prova. Ao escolher a recomendação de coluna sintética, o comando SHALL aplicá-la a toda tabela pendente, independentemente de ela ter ou não um candidato composto. + +#### Scenario: Chave composta confirmada pela escolha global + +- **WHEN** o operador escolhe a recomendação de chave composta e a sondagem por contagem completa confirma unicidade para uma das tabelas pendentes +- **THEN** o achado dessa tabela é resolvido como chave composta confirmada, com o mesmo veredito `confirmed` que o caminho automático produz + +#### Scenario: Pular não resolve nada + +- **WHEN** o operador responde vazio ou o stdin fecha (EOF) antes de uma resposta válida +- **THEN** todo achado pendente permanece exatamente como o caminho automático o deixou, sem sugestão adicional + +### Requirement: Uma resposta não reconhecida é reportada como inválida e perguntada de novo + +O comando SHALL NOT tratar uma resposta que não corresponda a nenhuma opção oferecida como um pedido para pular. Ele SHALL informar que a resposta foi inválida e perguntar novamente, até receber uma resposta reconhecida ou o stdin fechar. + +#### Scenario: Resposta inválida não pula silenciosamente + +- **WHEN** o operador digita algo que não corresponde a nenhuma opção do menu +- **THEN** o comando informa que a resposta foi inválida e pergunta de novo, em vez de tratar a tabela como pulada + +### Requirement: Coluna sintética nunca é afirmada como confirmada por dado + +Uma sugestão de coluna sintética SHALL NOT carregar um veredito de sondagem: sua correção não depende de nenhum dado existente, só da criação da coluna. O artefato `.sql` gerado para ela SHALL declarar em duas etapas — criação da coluna, depois promoção a chave primária — e SHALL observar que a criação de uma coluna `GENERATED ALWAYS AS IDENTITY` já reescreve a tabela. + +#### Scenario: Artefato de coluna sintética + +- **WHEN** um achado tem uma sugestão de coluna sintética +- **THEN** `suggested_keys.sql` emite a criação da coluna e a promoção a chave primária em duas etapas comentadas, com a ressalva sobre reescrita da tabela diff --git a/openspec/changes/archive/2026-08-12-audit-interactive-key-resolution/tasks.md b/openspec/changes/archive/2026-08-12-audit-interactive-key-resolution/tasks.md new file mode 100644 index 0000000..1b08695 --- /dev/null +++ b/openspec/changes/archive/2026-08-12-audit-interactive-key-resolution/tasks.md @@ -0,0 +1,51 @@ +## 1. Detecção de convenção de nome de PK + +- [x] 1.1 Adicionar `PrimaryKeyNames []NamingEvidence` e `SinglePKTables int` a `model.NamingDetection` +- [x] 1.2 Em `internal/profile/detect.go`, tabular o nome literal da coluna em toda tabela com `HasSingleColumnPK()`, ranqueado pelos mesmos limiares de proporção de `minRefAffixShare`/`minRefAffixCount` (limiares próprios `minPKNameShare`/`minPKNameCount`, mesmo valor) +- [x] 1.3 Teste unitário: schema com convenção clara de nome de PK produz `PrimaryKeyNames` corretos; schema sem PK nenhuma produz detecção vazia sem dividir por zero + +## 2. Modelo + +- [x] 2.1 Adicionar `SuggestSyntheticPrimaryKey SuggestionKind = "synthesize_primary_key"` em `internal/model/finding.go`, documentando que `Columns` carrega o nome da coluna nova e `KeyProbe` fica vazio (correção não depende de dado) + +## 3. Streams interativo + +- [x] 3.1 Adicionar campo exportado `Interactive bool` a `cli.Streams` +- [x] 3.2 `StdStreams` calcula `Interactive` a partir de `isTerminal(os.Stdin) && isTerminal(os.Stdout)` +- [x] 3.3 Teste: `Streams` construído por literal em teste (`In`/`Out` como `bytes.Buffer`) tem `Interactive` falso por padrão, sem exigir que todo teste existente passe o campo + +## 4. Resolução interativa no comando audit + +Revisado depois da primeira implementação: a versão original pausava tabela a tabela e aceitava um nome digitado livremente para a coluna sintética. Corrigido para uma decisão única por execução, com o nome sempre vindo da convenção detectada — ver design.md. + +- [x] 4.1 Adicionar flag `--profile` a `audit`, mesmo default e mesma ajuda de `discover` +- [x] 4.2 Carregar o profile e chamar `naming.Detect(cat.Schemas)` logo após `probeMissingKeys`, sem query nova +- [x] 4.3 Função pura `fkKeyCandidate(t model.Table, alreadyTried [][]string) ([]string, bool)`: retorna as colunas de toda FK de coluna única da tabela, quando houver duas ou mais e o conjunto ainda não estiver em `alreadyTried` +- [x] 4.4 Função pura `resolvePKName(evidence []model.NamingEvidence) string`: nome do topo do ranking (já ordenado por `profile.Detect`), ou `""` quando não há nenhum — sem limiar de confiança, sem essa distinção fazer mais sentido numa decisão global +- [x] 4.5 Orquestração `resolveUnconfirmedKeys`: chamada só quando `streams.Interactive && !opts.noProbeKeys` (gate no chamador, em `runAudit`); primeiro junta todo achado de PK ausente sem `KeyProbe == Confirmed` num `[]unresolvedKey`, sem perguntar nada; só depois imprime o resumo do cenário inteiro e pergunta uma vez +- [x] 4.6 Função pura de parsing da resposta — `a` escolhe a recomendação composta (só quando algum candidato existe), `b` escolhe a sintética (só quando há convenção), vazio pula; qualquer outra coisa devolve `ok == false`, sinal pro chamador reperguntar — testável sem I/O real +- [x] 4.7 `promptKeyResolution` reconstrói o prompt e lê de novo enquanto a resposta não for reconhecida, imprimindo "invalid answer" a cada tentativa inválida; EOF já resolve como pular porque `ReadString` devolve linha vazia junto com `io.EOF`, sem tratamento especial +- [x] 4.8 Escolha composta invoca `validate.ProbeUniqueness` na combinação de `fkKeyCandidate` de cada tabela pendente que tiver uma, aplica o resultado com `applyKeyProbeResults` (reuso do existente) +- [x] 4.9 Escolha sintética monta `model.Suggestion{Kind: SuggestSyntheticPrimaryKey, Columns: [nome da convenção], Note: proveniência com exemplos}` para toda tabela pendente, sem sondagem +- [x] 4.10 Sem terminal interativo, ou com `--no-probe-keys`: nenhuma linha de `streams.Err`/`streams.In` é tocada; saída idêntica à da change anterior — coberto por `TestNonInteractiveNeverPrompts` (integration) e pelo default de `Streams.Interactive` (unit) + +## 5. Relatório + +- [x] 5.1 `formatSuggestion` em `internal/report/terminal.go` renderiza `synthesize_primary_key` +- [x] 5.2 `suggestedKeysFile` em `internal/report/sql.go` ganha `writeSyntheticPrimaryKey`: cria a coluna, nota o custo de reescrita, promove em duas etapas como `writeConfirmedPrimaryKey` +- [x] 5.3 Serialização JSON do novo `SuggestionKind` coberta pelo teste de contrato existente (`json_contract.golden` regenerado para os dois campos novos de `naming_detection`) + +## 6. Verificação + +- [x] 6.1 `go test ./...` sem Docker e sem rede — verde +- [x] 6.2 Teste de integração cobrindo o caminho de chave composta via FKs, coluna sintética digitada, resposta vazia e a regressão não-interativa (`internal/cli/audit_interactive_integration_test.go`, fixture nova `missing_pk_fk_bridge.sql`) — escrito e verificado por `go vet -tags integration`; não pôde ser executado nesta sessão por falta de acesso ao daemon Docker no sandbox, mesma limitação registrada em `2026-08-12-efficiency-audit` +- [ ] 6.3 `golangci-lint run` zerado — binário não disponível neste sandbox; `go vet ./...` e `gofmt -l .` limpos como substituto parcial +- [x] 6.4 Revisar densidade de comentário antes de fechar + +## 7. Citação de exemplos na convenção detectada (adendo) + +- [x] 7.1 `model.NamingEvidence` ganha `Examples []string`, capado em `model.MaxNamingExamples` (3) +- [x] 7.2 `internal/profile/detect.go`: as quatro contagens (sufixo/prefixo de referência, prefixo de tabela, nome de PK) passam a acumular exemplos junto — capados na acumulação, não recortados depois +- [x] 7.3 `internal/cli/audit.go`: a nota de convenção aplicada sozinha e a linha de convenção fraca no prompt interativo citam os exemplos (`exampleSuffix`) +- [x] 7.4 `internal/report/discover.go`: a seção DETECTED do `discover` cita os mesmos exemplos por linha +- [x] 7.5 Contrato JSON, goldens e testes unitários/de renderização atualizados diff --git a/openspec/changes/archive/2026-08-12-efficiency-audit/.openspec.yaml b/openspec/changes/archive/2026-08-12-efficiency-audit/.openspec.yaml new file mode 100644 index 0000000..5081c98 --- /dev/null +++ b/openspec/changes/archive/2026-08-12-efficiency-audit/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-12 diff --git a/openspec/changes/archive/2026-08-12-efficiency-audit/design.md b/openspec/changes/archive/2026-08-12-efficiency-audit/design.md new file mode 100644 index 0000000..02de52f --- /dev/null +++ b/openspec/changes/archive/2026-08-12-efficiency-audit/design.md @@ -0,0 +1,90 @@ +## Context + +O `audit` foi desenhado na fase 2 como o comando que não erra: catálogo-puro, determinístico, imune a falso positivo, executável num banco onde a inferência não teria nada a dizer. Esta change amplia o que ele aponta sem abrir mão dessa identidade — com uma exceção deliberada e cercada: a sondagem de chave, que lê contagem de dado real. + +A pergunta que governa cada decisão aqui é a mesma da fase 2: a ferramenta será apontada para o banco de produção de outra pessoa. Uma recomendação errada custa uma conversa; uma query que trava o servidor custa a adoção. Toda leitura de dado nesta change é opcional, limitada por teto e por `statement_timeout`, e degrada para "não sondado, registrado na cobertura" — nunca para trava. + +Restrições herdadas, todas preservadas: read-only absoluto; nenhum valor de dado do usuário em qualquer saída (a sondagem emite só contagens); nenhuma afirmação sem evidência; silêncio nunca reportado como ausência de problema; nenhum falso positivo confirmado. + +## Goals / Non-Goals + +**Goals** + +Apontar tabela sem PK e, quando o catálogo permitir, o caminho de promoção sem custo. Confirmar por contagem a chave — simples ou composta — quando não há unique promovível. Apontar coluna quente sem índice a partir do uso real que o extrator já lê, com o tipo de índice certo para o operador e o tipo da coluna. Manter o `audit` num comando só. + +**Non-Goals** + +Não reescrever o schema: a ferramenta gera `.sql` revisável, nunca executa DDL. Não recomendar índice por palpite de nome — só por uso observado no código. Não sondar unicidade de todo subconjunto de colunas: a explosão combinatória é cortada por heurística de catálogo. Não cobrir `GiST`, `SP-GiST`, `BRIN` nesta change — `btree`/`hash`/`GIN` e os métodos de extensão (`gin_trgm_ops`, `HNSW`) cobrem o que o extrator consegue justificar; o resto entra quando houver operador extraído que o exija. + +## Decisions + +### Avaliação da ideia — por que ela cabe, e onde ela quase não coube + +A ideia é sólida porque o `audit` já é, na prática, um auditor de eficiência estrutural: seus dois achados atuais são exatamente isso. Somar PK ausente e índice ausente é continuar a mesma frase, não começar outra. E a matéria-prima já existe: o catálogo dá PK, unique, índice e tipo; o extrator da fase 6 dá o uso real das colunas. A change é mais montagem do que invenção. + +Onde ela quase não coube: **"identificar a PK vendo colunas sem repetição"** colide de frente com a regra 5. O catálogo não sabe se uma coluna sem unique é única — só os dados sabem. As três saídas honestas eram: (a) só promover unique existente, que não cobre a tabela sem nenhum unique; (b) estimar por `pg_stats.n_distinct`, que é palpite e não pode ser afirmado; (c) provar por contagem contra os dados. Escolhida a (a) com fallback (c): promoção quando o catálogo basta, sondagem por contagem quando não. A (b) fica de fora porque estimativa afirmada como fato é o falso positivo que a regra 5 proíbe. + +### A sondagem de chave mora em `internal/validate`, não em `internal/audit` + +A arquitetura tem uma fronteira dura: `internal/validate` é a única camada que lê dado de tabela do usuário. A sondagem de unicidade lê dado — logo mora lá, ao lado da validação de contenção, reusando o mesmo `Beginner`, a mesma transação com `SET LOCAL statement_timeout`, a mesma disciplina de cancelamento por contexto. O `audit` continua catálogo-puro; quem costura catálogo, extrator e sondagem é o `cli`, exatamente como o `discover` já costura catálogo, inferência e validação. + +### Unicidade só confirma em varredura completa + +Amostra prova contenção alta, mas nunca unicidade: uma única duplicata fora da amostra derruba a chave. Então a sondagem de chave ignora o modo amostrado e roda `count(*) = count(DISTINCT (cols))` na tabela inteira. Tabela grande demais para caber no `statement_timeout` sai como `unverified` e entra na cobertura — nunca como chave confirmada. É a mesma assimetria da fase 5: recuperar menos e nunca errar vence. + +A consulta é `SELECT count(*), count(DISTINCT (c1,...,cn)), count(*) FILTER (WHERE c1 IS NULL OR ...)`. Chave viável exige `total = distinct` e `nulls = 0`. Só três inteiros saem — nenhum valor de coluna, nem em struct, nem em log, nem em erro. A varredura de vazamento cobre essa saída. + +### O teto de tamonho é o que mantém o `audit` barato por padrão + +A sondagem roda automaticamente só para tabelas abaixo de um teto de linhas estimadas, conservador e configurável (`--probe-keys-max-rows`, padrão a fixar na implementação, ordem de milhões). Acima do teto, a tabela sai como "chave não sondada — grande demais" na cobertura, com a promoção de unique ainda oferecida se houver. `--no-probe-keys` desliga a leitura de dado inteira e devolve o `audit` ao catálogo-puro. A decisão pende para sondar por padrão porque o pedido era um fallback que acontece, não um que o usuário precisa lembrar de ligar — mas o teto garante que "por padrão" nunca significa "query pesada sem aviso". + +### Candidatos a chave composta saem do catálogo, não da força bruta + +Sondar todo subconjunto de colunas é fatorial e inviável. A heurística de nomeação de candidatos é catálogo-only e barata: +- Coluna única `NOT NULL` cujo `n_distinct` estimado se aproxima de `reltuples` → candidata a PK de coluna única. +- Conjunto de colunas de um índice não-único `NOT NULL` já existente → candidato a PK composta (o schema já agrupou essas colunas por algum motivo). +- Teto de sondagens por tabela, para que uma tabela com muitos índices não vire uma rajada de varreduras completas. + +Estimativa aqui só **prioriza** o que sondar; a confirmação vem sempre da contagem completa. Estimativa errada custa uma sondagem à toa, nunca um falso positivo. + +### Tipo de índice: `btree` é o padrão seguro, o resto exige o operador extraído + +O extrator hoje só vê `=` entre duas colunas qualificadas. Para recomendar tipo, ele passa a emitir o operador de um predicado de coluna qualificada. O mapa: +- `=`, `<`, `>`, `BETWEEN`, `LIKE 'prefixo%'` → `btree`. `btree` serve igualdade e faixa; é a escolha correta e nunca errada. +- `LIKE '%infixo%'`, `ILIKE` → `GIN gin_trgm_ops`, **se `pg_trgm` presente**; senão `btree` com nota de que o infixo pede `pg_trgm`. +- `@>`, `<@`, `?`, `?|`, `?&` em `jsonb`/array → `GIN`. +- `@@` (full-text) → `GIN`. +- Coluna de tipo `vector` com operador de distância (`<->`, `<=>`, `<#>`) → `HNSW`, **se `pgvector` presente**. + +`hash` fica de fora da recomendação confirmada: só ganha de `btree` em igualdade pura e carrega ressalvas que não valem a economia numa recomendação automática. Recomendar `btree` onde `hash` bastaria custa alguns bytes; recomendar `hash` onde a coluna também é ordenada custa um índice inútil. `btree` como padrão nunca é o achado errado. + +### Oportunidades de extensão — pgvector, pg_trgm e vizinhas + +O pedido incluía avaliar libs/extensões que agregam. A inferência de tipo é o gancho natural para elas, e o contrato de detecção já existe no projeto (`pg_stat_statements` mostra como: perguntar a `pg_extension`, degradar sem erro na ausência). Nesta change entram, gated por presença: +- **pg_trgm**: recomendação `GIN gin_trgm_ops` para `LIKE`/`ILIKE` de infixo — o padrão de busca textual mais comum em schema legado sem índice adequado. +- **pgvector**: recomendação `HNSW` (ou `ivfflat`, com nota de trade-off recall/velocidade) para coluna `vector` cruzada por operador de distância. Detectar coluna `vector` sem nenhum índice de vizinhança é um achado de alto valor onde a extensão já foi adotada mas o índice esqueceram. +- **btree_gin / btree_gist**: fora de escopo confirmado, anotadas como extensão futura para predicado misto (igualdade + contenção na mesma coluna), que o extrator ainda não distingue. + +Regra transversal: extensão ausente nunca vira erro nem recomendação impossível. Vira `btree` com nota, ou omissão do achado quando não há alternativa honesta. Recomendar `CREATE EXTENSION` fica como sugestão no artefato `.sql`, comentada, nunca como pré-requisito silencioso. + +### Artefatos `.sql` com o custo de lock à mostra + +`suggested_indexes.sql` usa `CREATE INDEX CONCURRENTLY`, que não trava escrita — o único jeito honesto de sugerir índice em produção. `suggested_keys.sql` prefere `ADD CONSTRAINT ... USING INDEX` sobre uma unique já existente (lock curto) e, quando cria do zero, comenta o custo do lock de `ADD PRIMARY KEY` e sugere o caminho em duas etapas (criar unique concurrently, depois promover). O artefato é revisável e nunca executado pela ferramenta — a regra read-only não tem exceção. + +## Risks / Trade-offs + +- **Leitura de dado por padrão muda o contrato do `audit`.** Mitigado por teto conservador, `--no-probe-keys`, e cobertura explícita das tabelas não sondadas. É a decisão que mais merece o olhar do usuário na aprovação. +- **Recomendação de índice pode ter falso positivo** (coluna quente que o DBA decidiu não indexar por escrita pesada). Aceito pela mesma razão que a fase 2 aceitou para FK sem índice: falso positivo em recomendação custa conversa, falso negativo custa incidente. O limiar de recorrência corta o ruído. +- **Inferência de tipo depende do extrator degradável.** Operador não reconhecido → sem recomendação de tipo para aquela coluna, cai no `btree` padrão ou some. Nunca recomenda tipo errado. +- **PK composta confirmada por contagem é cara** na tabela grande. O teto e o `statement_timeout` a barram; ela sai `unverified`, não confirmada. + +## Migration + +Consumidor de JSON: `Finding` ganha `suggestion` opcional; ausente nos achados antigos, presente nos novos. `schema_version` incrementa. Nenhum campo existente muda de forma. + +Operador: quem depende de `audit` barato em CI passa `--no-probe-keys` para manter o comportamento catálogo-puro anterior. + +## Open Questions + +- Valor exato do teto padrão de `--probe-keys-max-rows` — a fixar na implementação medindo contra as fixtures e o corpus da change de benchmark. +- Se a recomendação `ivfflat` vs `HNSW` para `pgvector` deve depender de tamanho estimado da tabela (ivfflat escala melhor em ingestão, HNSW em recall) — decidir na implementação do gerador de artefato. diff --git a/openspec/changes/archive/2026-08-12-efficiency-audit/proposal.md b/openspec/changes/archive/2026-08-12-efficiency-audit/proposal.md new file mode 100644 index 0000000..a93652c --- /dev/null +++ b/openspec/changes/archive/2026-08-12-efficiency-audit/proposal.md @@ -0,0 +1,44 @@ +## Why + +O `audit` hoje emite dois achados de eficiência estrutural — constraint `NOT VALID` e FK sem índice do lado filho — e ambos provaram valor: saem direto do catálogo, custam quase nada e são imunes a falso positivo. Um banco legado carrega mais problemas de eficiência do que esses dois, e todos são invisíveis do mesmo jeito que os relacionamentos não declarados: o catálogo sabe o suficiente para apontar, ninguém olhou. + +Dois deles são recorrentes em schema de gestão pública, que é o alvo declarado do projeto: + +1. **Tabela sem chave primária.** Sem PK a tabela não tem identidade de linha, replicação lógica não a cobre, e todo `UPDATE`/`DELETE` por linha vira varredura. O catálogo diz que não há PK; muitas vezes diz também qual coluna já é `UNIQUE NOT NULL` e poderia ser promovida sem custo. Quando não diz, uma sondagem de contagem — sem nenhum valor saindo — prova a unicidade contra os dados e nomeia a chave, inclusive composta. + +2. **Coluna quente sem índice.** O extrator de junção da fase 6 já lê view, função e `pg_stat_statements` e sabe quais colunas o código real cruza. Uma coluna que aparece repetidamente em predicado de junção mas não lidera nenhum índice é uma varredura sequencial que o schema pede em toda execução. O mesmo extrator, estendido para reconhecer o operador do predicado, permite recomendar não só o índice mas o **tipo** certo: `btree` para igualdade e faixa, `GIN` para contenção em `jsonb`/array e para `LIKE` de infixo com `pg_trgm`, `HNSW` para coluna `vector` com `pgvector`. + +Isso mantém o `audit` fiel à sua identidade — apontar fato, não hipótese — e estende a única sondagem de dado que o produto já faz (validação por contagem) a um segundo uso que respeita as mesmas cinco regras invioláveis. + +## What Changes + +- `internal/sqlprobe`: o extrator passa a emitir, além da igualdade entre duas colunas qualificadas, **evidência de predicado** — uma referência qualificada, o operador e a classe do lado direito (literal, parâmetro, referência). Nova saída, sem tocar no contrato da evidência de junção existente. Degradação preservada: predicado não reconhecido é ignorado sem erro. +- `internal/catalog`: leitura da lista de extensões instaladas (`pg_extension`) para dentro do modelo, para que a recomendação de tipo de índice só sugira `GIN gin_trgm_ops`, `HNSW` etc. quando a extensão que os suporta existir. +- `internal/model`: novos `FindingKind` — `missing_primary_key` e `unindexed_hot_column`; struct `Suggestion` opcional no `Finding`, aditiva ao contrato JSON, carregando o tipo de sugestão, as colunas envolvidas, o método de índice recomendado e o veredito da sondagem de chave. +- `internal/audit`: dois geradores novos — tabela sem PK (com o caminho de promoção quando há `UNIQUE NOT NULL`) e coluna quente sem índice (com a inferência de tipo). Ambos permanecem catálogo-puros: nenhuma linha de dado é lida aqui. +- `internal/validate`: `ProbeUniqueness` — a sondagem de unicidade de um conjunto de colunas por **contagem apenas** (`count(*)`, `count(DISTINCT ...)`, `count(*) FILTER (WHERE ... IS NULL)`). É a **única** camada que lê dado de usuário, e a sondagem respeita isso. Unicidade só é confirmada em varredura completa: amostra nunca confirma, porque a duplicata pode estar fora dela. +- `internal/cli`: o comando `audit` passa a orquestrar a evidência de predicado (via `sqlprobe.Probe`) e a sondagem de chave (via `validate.ProbeUniqueness`), com teto de tamanho configurável e cobertura para as tabelas grandes demais para sondar. +- `internal/report`: títulos e renderização dos dois achados novos; artefatos `.sql` revisáveis — `suggested_keys.sql` (`ADD CONSTRAINT ... USING INDEX`, `ADD PRIMARY KEY`) e `suggested_indexes.sql` (`CREATE INDEX CONCURRENTLY ... USING `). `schema_version` do JSON incrementado. + +**A superfície do `audit` deixa de ser catálogo-puro por padrão.** A sondagem de chave lê dado (só contagens) para tabelas abaixo de um teto conservador, configurável, com as maiores registradas na cobertura como não sondadas. É a mudança de contrato desta change e está isolada atrás de flag e teto. + +## Capabilities + +### Modified Capabilities + +- `structural-audit`: ganha os achados de tabela sem PK e de coluna quente sem índice, a recomendação de tipo de índice, e a sondagem opcional de chave por contagem — com a regra de que unicidade só é confirmada em varredura completa e o teto de tamanho aparece na cobertura. +- `usage-evidence`: o extrator passa a reconhecer o operador de um predicado de coluna qualificada, alimentando a recomendação de índice. Igualdade de junção segue como está. + +### New Capabilities + +Nenhuma. Tudo estende capability existente, para não fragmentar o `audit` em dois comandos. + +## Impact + +`internal/validate` deixa de ser exclusivo do `discover` e passa a ser consumido também pelo `audit`. A fronteira "validate é a única camada que lê dado de usuário" é preservada — a sondagem de chave mora lá, não no `audit`. + +Nenhuma dependência nova no binário. `pg_trgm`, `pgvector` e `btree_gin` são detectadas via `pg_extension` e usadas só quando presentes; ausência degrada para `btree` com nota, nunca para erro — mesmo contrato de `pg_stat_statements`. + +Fixtures novas em `testdata/`: tabela sem PK com coluna promovível, tabela sem PK com chave composta plantada, coluna quente de view sem índice, coluna `jsonb` com contenção, e — atrás de detecção de extensão — coluna `vector`. Todas plantam valor reconhecível para a varredura de vazamento, que passa a cobrir a saída da sondagem de contagem. + +`schema_version` do JSON incrementa: os campos são aditivos, mas o incremento sinaliza a um consumidor que o `Finding` pode agora carregar `Suggestion`. diff --git a/openspec/changes/archive/2026-08-12-efficiency-audit/specs/structural-audit/spec.md b/openspec/changes/archive/2026-08-12-efficiency-audit/specs/structural-audit/spec.md new file mode 100644 index 0000000..b765f56 --- /dev/null +++ b/openspec/changes/archive/2026-08-12-efficiency-audit/specs/structural-audit/spec.md @@ -0,0 +1,83 @@ +## ADDED Requirements + +### Requirement: Tabela sem chave primária é reportada + +O comando SHALL reportar toda tabela em escopo que não tenha chave primária, acompanhada da estimativa de linhas, que é o que indica a gravidade. + +Sem PK a tabela não tem identidade de linha: replicação lógica não a cobre e todo `UPDATE`/`DELETE` por linha vira varredura. Quando existe um `UNIQUE` cujas colunas são todas `NOT NULL`, o achado SHALL oferecer a promoção dessa unique a PK, que é um caminho provado pelo catálogo e de custo baixo. + +#### Scenario: Tabela sem PK com unique promovível + +- **WHEN** uma tabela não tem PK mas tem uma constraint `UNIQUE` com todas as colunas `NOT NULL` +- **THEN** o achado aparece com a sugestão de promover essa unique a chave primária + +#### Scenario: Tabela sem PK e sem unique promovível + +- **WHEN** uma tabela não tem PK nem unique promovível +- **THEN** o achado aparece marcado como precisando de sondagem de chave, sem afirmar qual é a chave + +#### Scenario: Tabela com PK não gera achado + +- **WHEN** todas as tabelas do escopo têm chave primária +- **THEN** nenhum achado desse tipo é emitido + +### Requirement: A chave sugerida é confirmada por contagem, nunca por estimativa + +Quando não há unique promovível, o comando MAY sondar a unicidade de um conjunto de colunas para nomear a chave. A sondagem SHALL emitir apenas contagens — nenhum valor de coluna em struct, log, JSON ou erro. Unicidade SHALL ser confirmada apenas em varredura completa da tabela: modo amostrado nunca confirma chave, porque uma duplicata pode estar fora da amostra. + +Um conjunto sondado que estoure o `statement_timeout` SHALL sair como `unverified` e a execução prossegue. Estimativa de `n_distinct` MAY priorizar o que sondar, mas nunca SHALL ser afirmada como chave. + +#### Scenario: Chave composta confirmada por contagem + +- **WHEN** a sondagem completa uma tabela cuja unicidade real é composta e as contagens provam `total = distinct` sem nulos +- **THEN** o achado nomeia a chave composta como confirmada + +#### Scenario: Amostra não confirma chave + +- **WHEN** a tabela é grande e só pôde ser lida por amostra +- **THEN** nenhuma chave sai como confirmada; a chave sai `unverified` e a tabela consta da cobertura + +#### Scenario: Sondagem não vaza valor + +- **WHEN** a sondagem roda contra fixtures com valores plantados +- **THEN** nenhum desses valores aparece na saída em terminal, no JSON ou no log + +### Requirement: Coluna quente sem índice é reportada com o tipo de índice apropriado + +O comando SHALL reportar toda coluna que apareça em predicado de junção ou de filtro no código real — view, função ou `pg_stat_statements` — com recorrência acima do limiar configurável e sem índice que a lidere. O achado SHALL recomendar o método de índice apropriado ao operador observado e ao tipo da coluna. + +A recomendação SHALL ser `btree` por padrão, servindo igualdade e faixa. `GIN` SHALL ser recomendado para contenção em `jsonb`/array e para full-text; `GIN gin_trgm_ops` para `LIKE`/`ILIKE` de infixo quando `pg_trgm` estiver instalada; um método de vizinhança para coluna `vector` quando `pgvector` estiver instalada. Extensão ausente nunca vira erro nem recomendação impossível: degrada para `btree` com nota ou omite o achado. + +#### Scenario: Coluna de junção sem índice + +- **WHEN** uma view cruza repetidamente uma coluna que não lidera nenhum índice +- **THEN** o achado aparece recomendando `btree` sobre essa coluna + +#### Scenario: Contenção em jsonb sem GIN + +- **WHEN** uma função usa `@>` sobre uma coluna `jsonb` sem índice `GIN` +- **THEN** o achado recomenda `GIN` para essa coluna + +#### Scenario: Infixo sem pg_trgm + +- **WHEN** um predicado `LIKE '%x%'` incide sobre uma coluna sem índice e `pg_trgm` não está instalada +- **THEN** o achado recomenda `btree` com a nota de que o infixo pede `pg_trgm`, sem falhar + +#### Scenario: FK sem índice não é duplicada + +- **WHEN** uma coluna já é reportada por `fk_without_index` +- **THEN** ela não gera também um achado de coluna quente + +### Requirement: A leitura de dado da sondagem é opcional e limitada por teto + +A sondagem de chave SHALL rodar automaticamente apenas para tabelas abaixo de um teto de linhas estimadas, configurável. Tabelas acima do teto SHALL constar da cobertura como não sondadas, mantendo a oferta de promoção de unique quando houver. Uma flag SHALL desligar a leitura de dado por inteiro, devolvendo o comando ao comportamento catálogo-puro. + +#### Scenario: Tabela grande não é sondada + +- **WHEN** uma tabela sem PK excede o teto de tamanho +- **THEN** a sondagem não roda contra ela e a cobertura registra a tabela como não sondada + +#### Scenario: Leitura de dado desligada + +- **WHEN** o comando roda com a sondagem desligada +- **THEN** nenhuma transação de leitura de dado é aberta e os achados de PK saem apenas do catálogo diff --git a/openspec/changes/archive/2026-08-12-efficiency-audit/specs/usage-evidence/spec.md b/openspec/changes/archive/2026-08-12-efficiency-audit/specs/usage-evidence/spec.md new file mode 100644 index 0000000..88c9faf --- /dev/null +++ b/openspec/changes/archive/2026-08-12-efficiency-audit/specs/usage-evidence/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: O extrator reconhece o operador de um predicado de coluna qualificada + +Além da igualdade entre duas colunas qualificadas, o extrator SHALL reconhecer um predicado cujo lado esquerdo é uma referência de coluna qualificada e classificar o operador — igualdade, comparação de faixa, `LIKE`/`ILIKE`, contenção (`@>`, `<@`, `?`, `?|`, `?&`), full-text (`@@`) e distância de vetor. O lado direito SHALL ser classificado em literal, parâmetro ou referência, e um `LIKE` sobre literal constante SHALL distinguir prefixo de infixo. + +Essa evidência alimenta a recomendação de tipo de índice. A extração de junção existente MUST permanecer inalterada, e predicado que o extrator não reconhece MUST ser ignorado sem erro: a degradação da fase 6 vale igual aqui. + +#### Scenario: Predicado de contenção é classificado + +- **WHEN** uma função contém `t.dados @> '...'::jsonb` sobre uma coluna qualificada +- **THEN** a evidência de predicado registra a coluna resolvida e o operador de contenção + +#### Scenario: LIKE de infixo é distinguido do de prefixo + +- **WHEN** o SQL contém `t.nome LIKE '%x%'` e, em outro ponto, `t.cod LIKE 'x%'` +- **THEN** o primeiro é classificado como infixo e o segundo como prefixo + +#### Scenario: Operador desconhecido não derruba a extração + +- **WHEN** um predicado usa um operador que o extrator não classifica +- **THEN** aquele predicado é ignorado sem erro e a extração das demais fontes segue + +#### Scenario: A junção existente segue intacta + +- **WHEN** uma view cruza duas colunas por igualdade com aliases +- **THEN** a evidência de junção continua sendo extraída como antes, ao lado da nova evidência de predicado diff --git a/openspec/changes/archive/2026-08-12-efficiency-audit/tasks.md b/openspec/changes/archive/2026-08-12-efficiency-audit/tasks.md new file mode 100644 index 0000000..021289e --- /dev/null +++ b/openspec/changes/archive/2026-08-12-efficiency-audit/tasks.md @@ -0,0 +1,66 @@ +## 1. Modelo e catálogo + +- [x] 1.1 Adicionar em `internal/model/finding.go` os `FindingKind` `missing_primary_key` e `unindexed_hot_column` +- [x] 1.2 Adicionar o struct `Suggestion` (tipo de sugestão, colunas, método de índice, veredito da sondagem) como campo opcional aditivo do `Finding`, com tags JSON `omitempty` +- [x] 1.3 Adicionar em `internal/model/schema.go` os helpers `HasPrimaryKey`, `PromotableUnique` (unique cujas colunas são todas `NOT NULL`) e `IsIndexedLeading` reusado +- [x] 1.4 Definir em `internal/model` a classe de operador de predicado e o mapa operador+tipo → método de índice (`btree`/`gin`/`gin_trgm_ops`/`hnsw`) — `hash` ficou fora, ver design.md +- [x] 1.5 Adicionar ao modelo a lista de extensões instaladas e o acessor de presença por nome +- [x] 1.6 Ler `pg_extension` em `internal/catalog`, registrando a lista no resultado; ausência de leitura degrada sem erro +- [x] 1.7 Incrementar `schema_version` do JSON e ajustar o teste de contrato + +## 2. Extrator de predicado + +- [x] 2.1 Estender `internal/sqlprobe/extract.go` para emitir evidência de predicado: referência qualificada + operador + classe do lado direito, sem alterar a extração de junção existente +- [x] 2.2 Reconhecer os operadores `=`, `<`, `>`, `LIKE`, `ILIKE`, `@>`, `<@`, `?`, `?|`, `?&`, `@@` e os de distância de `pgvector`, classificando cada um +- [x] 2.3 Distinguir `LIKE 'prefixo%'` de `LIKE '%infixo%'` pelo literal, quando o lado direito for string constante +- [x] 2.4 Resolver a referência de predicado contra o catálogo em `internal/sqlprobe/probe.go`, adicionando `PredicateEvidence` ao `Evidence` — dedup inclui o objeto de origem, ao contrário da junção, porque a recorrência entre objetos é o sinal de que a coluna quente precisa (ver design.md) +- [x] 2.5 Garantir que predicado não reconhecido é ignorado sem erro — teste com SQL malformado e com operador desconhecido + +## 3. Achados de auditoria (catálogo-puro) + +- [x] 3.1 Implementar em `internal/audit` o gerador de tabela sem PK, com o caminho de promoção quando há `PromotableUnique` +- [x] 3.2 Marcar no `Suggestion` da tabela sem PK e sem unique promovível que ela precisa de sondagem de chave +- [x] 3.3 Implementar o gerador de coluna quente sem índice a partir de `JoinEvidence` + `PredicateEvidence`, cortando por limiar de recorrência configurável +- [x] 3.4 Não duplicar o achado `fk_without_index` existente: coluna já coberta por ele não vira `unindexed_hot_column` +- [x] 3.5 Inferir o método de índice pelo operador extraído e pelo tipo da coluna, respeitando a presença de extensão; sem operador reconhecido, cair em `btree`; sem recomendação honesta (ex.: contenção em tipo sem classe de operador GIN padrão), omitir o achado +- [x] 3.6 Manter `internal/audit` sem nenhuma leitura de dado — teste que falha se o pacote importar `validate`, `db` ou `pgx` + +## 4. Sondagem de chave (única camada que lê dado) + +- [x] 4.1 Implementar `ProbeUniqueness` em `internal/validate`, reusando `Beginner`, transação e `SET LOCAL statement_timeout` +- [x] 4.2 Consulta de contagem `count(*)`, `count(DISTINCT (cols))`, `count(*) FILTER (WHERE ... IS NULL)` — só inteiros saem +- [x] 4.3 Confirmar unicidade apenas em varredura completa; a função nunca amostra (nenhum código de amostragem existe neste caminho) +- [x] 4.4 Estouro de `statement_timeout` marca a chave `unverified` e a execução segue +- [x] 4.5 Nomear candidatos a partir do catálogo em `internal/cli`: colunas de índice não-único `NOT NULL`, com fallback para cada coluna `NOT NULL` individual, e teto de sondagens por tabela — decisão registrada no design.md: sem estimativa de `n_distinct`, a seleção é só catálogo, e a confirmação vem sempre da contagem +- [x] 4.6 Cancelamento por contexto encerra a sondagem no servidor sem deixar conexão pendente (herdado do padrão de `runQuery`) + +## 5. Orquestração no comando audit + +- [x] 5.1 Chamar `sqlprobe.Probe` no `runAudit` para obter junção e predicado, registrando a disponibilidade de `pg_stat_statements` na cobertura +- [x] 5.2 Chamar `validate.ProbeUniqueness` para as tabelas sem PK e sem unique promovível, abaixo do teto de tamanho +- [x] 5.3 Costurar o veredito da sondagem no `Suggestion` do achado correspondente +- [x] 5.4 Adicionar as flags `--probe-keys-max-rows`, `--no-probe-keys` e `--recurrence-min`, com padrões conservadores +- [x] 5.5 Registrar na cobertura as tabelas não sondadas por excederem o teto (`Coverage.KeyProbesSkipped`, campo novo) + +## 6. Relatório e artefatos + +- [x] 6.1 Adicionar os títulos dos dois achados em `internal/report/terminal.go` e renderizar o `Suggestion` +- [x] 6.2 Serializar `Suggestion` no JSON sem vazar valor de dado — só nomes de objeto, tipos e contagens +- [x] 6.3 Gerar `suggested_keys.sql`: `ADD PRIMARY KEY USING INDEX` quando há unique (live), caminho em duas etapas comentado quando cria do zero +- [x] 6.4 Gerar `suggested_indexes.sql` com `CREATE INDEX CONCURRENTLY ... USING ( [])` comentado; `CREATE EXTENSION IF NOT EXISTS` comentado quando o método depende de uma +- [x] 6.5 Afirmar escopo limpo quando não há achado de eficiência, sem confundir com ausência de análise (herdado de `writeNothingFound`) + +## 7. Fixtures e verificação + +- [x] 7.1 Fixture `missing_pk_promotable`: tabela sem PK com `UNIQUE NOT NULL` promovível +- [x] 7.2 Fixture `missing_pk_composite`: tabela sem PK cuja unicidade real é composta, com índice não-único plantado sobre as colunas — e uma segunda tabela na mesma fixture com duplicata plantada, para provar que a sondagem nunca confirma por engano +- [x] 7.3 Fixture `hot_column_unindexed`: view + função que cruzam uma coluna sem índice, recorrente +- [x] 7.4 Fixture `jsonb_containment`: coluna `jsonb` com `@>` em view e função, sem `GIN` +- [x] 7.5 Fixture `pgvector_unindexed`, atrás de detecção de extensão (`testutil.TryPostgresImageDSN`, pula se a imagem `pgvector/pgvector:pg13` não sobe): coluna `vector` sem índice de vizinhança +- [x] 7.6 Plantar valor reconhecível em todas e estender a varredura de vazamento à saída da sondagem de contagem (`PlantedValues` em `internal/testutil/leak.go` + `TestEfficiencyFindingsNeverLeakUserData`) +- [x] 7.7 Teste unitário do mapa operador+tipo → método de índice, cobrindo presença e ausência de extensão (`internal/model/evidence_test.go`) +- [x] 7.8 Teste de integração provando que a sondagem de chave confirma um caso real e nunca confirma uma duplicata plantada, e sai `unverified` no estouro de timeout (`internal/validate/keyprobe_integration_test.go`) — escrito e verificado por `go vet -tags integration`; não pôde ser executado nesta sessão por falta de acesso ao daemon Docker no sandbox +- [x] 7.9 Teste provando que `internal/audit` não lê dado (`TestPackageNeverReadsData`) +- [x] 7.10 `golangci-lint run` zerado; `go test ./...` sem Docker e sem rede; binário sem dependência nova (`go.mod`/`go.sum` inalterados) +- [x] 7.11 Revisar densidade de comentário antes de fechar +- [ ] 7.12 `openspec validate efficiency-audit` — CLI `openspec` não disponível neste sandbox; estrutura da change conferida manualmente contra as changes arquivadas (`catalog-inspection`, `fk-candidate-inference`) como substituto diff --git a/openspec/changes/archive/2026-08-13-similaridade-lexical-candidatos/design.md b/openspec/changes/archive/2026-08-13-similaridade-lexical-candidatos/design.md new file mode 100644 index 0000000..f752fb8 --- /dev/null +++ b/openspec/changes/archive/2026-08-13-similaridade-lexical-candidatos/design.md @@ -0,0 +1,107 @@ +## Métrica de similaridade + +Coeficiente de Dice sobre conjuntos de trigrama de caractere, case-insensitive: + +``` +similaridade(a, b) = 2 × |trigramas(a) ∩ trigramas(b)| / (|trigramas(a)| + |trigramas(b)|) +``` + +Trigramas extraídos com padding de borda — duas posições à esquerda, uma à direita, mesma +convenção do `pg_trgm` — para que prefixo e sufixo curtos ainda produzam trigrama e a +similaridade não fique artificialmente baixa em nomes de poucas letras. String vazia de +qualquer lado retorna 0 diretamente, sem passar pelo cálculo. + +**Por que Dice sobre trigrama, e não Jaro-Winkler ou os dois combinados:** a análise anterior +desta sessão levantou as duas opções lado a lado. Trigrama generaliza melhor para reordenação +(`idkey_operador` vs `operadorbasecalculo`, `atorevogacao` vs `ato`), que é o padrão dominante +nos três exemplos documentados do corpus pt-BR. Jaro-Winkler favorece prefixo comum e +generaliza melhor para erro de digitação, que não é o padrão observado aqui. Manter as duas e +tomar o maior adicionaria uma segunda métrica, um segundo conjunto de testes e uma segunda +constante de calibração para um ganho não demonstrado nos casos reais que motivam esta change +— contraria o princípio do projeto de não adicionar superfície sem medição que justifique. +Se o corpus revelar depois que a métrica não generaliza para outro padrão de abreviação, a +segunda métrica entra como change própria, com o número que a motivou. + +**Por que não `pg_trgm` da extensão:** exigiria a extensão instalada no banco-alvo — que o +produto não controla e não pode instalar (regra 1, read-only absoluto) — para uma conta que +strings de poucos caracteres resolvem em microssegundos dentro do próprio binário. GitLab, o +maior schema do corpus, já lista `pg_trgm` entre as extensões que o **schema em si** precisa; +não é razão para o `pgfathom` depender dela para o próprio cálculo interno. + +## Dois limiares, dois papéis + +Este change introduz um limiar nomeado com cuidado para não colidir com o que já existe: + +- **Limiar de similaridade** (`MinNameSimilarity`, padrão 0.30 — ver correção abaixo) — decide + se a via de fallback **gera** candidato para aquele par coluna/tabela. Comparável ao papel + que `Profile.TableForms` já cumpre na via por afixo: decide existência, não pontuação. +- **Limiar de pontuação** (`MinScore`, já existente, `DefaultMinScore = 0.5`) — decide se o + `MetaScore` final, somando todos os sinais do candidato, sobrevive até a validação. Não + muda nesta change, e vale por igual para candidato de qualquer origem. + +**Correção feita durante a implementação, não na análise que motivou esta change:** o valor de +partida discutido antes de medir era 0.65. Rodando `TrigramSimilarity` contra os três pares +reais do corpus que motivam esta change, os três ficam abaixo disso — +`operador`/`operadorbasecalculo` = 0.552, `tptramite`/`tramitetipo` = 0.545, +`atorevogacao`/`ato` = 0.353. Um limiar de 0.65 geraria candidato para nenhum dos três casos +que a change existe para alcançar — a estimativa anterior era um chute sem medição, e a +medição chegou antes da entrega em vez de depois. + +Baixar o limiar de geração para 0.30 (abaixo do menor dos três, com margem) é seguro pelo +mesmo motivo que `Generate` já documenta para o resto do pipeline: "gera liberalmente e corta +estritamente" (`internal/infer/generate.go`, doc de `Generate`). O limiar de similaridade não +precisa fazer o trabalho de proteção contra ruído sozinho — o `SigNameSimilarity` tem peso +baixo (teto 0.12) e só empurra um candidato além do limiar de pontuação (0.5) se os outros +sinais (tipo idêntico, alvo único, not null) já contribuírem a maior parte; um candidato fraco +nos dois eixos continua caindo no corte de pontuação e aparece em `Discarded`, nunca em +silêncio. Quem protege contra confirmação errada continua sendo a validação contra dado, não +este limiar. + +Um candidato pode cruzar o limiar de similaridade e ainda cair no de pontuação, se os demais +sinais forem fracos (tipo só compatível, alvo ambíguo, sem índice) — comportamento idêntico ao +que já acontece hoje na via por afixo com casamento normalizado fraco. + +## Peso do sinal: fixo no teto, proporcional na prática + +`SigNameSimilarity` usa peso graduado, não fixo — `nameSimilarityWeight(score) = +weightNameSimilarityMax × score` —, seguindo o mesmo padrão que `arityWeight` já estabelece em +`score.go` para sinais cujo peso depende de uma medida contínua, não de um fato binário. + +`weightNameSimilarityMax` fica abaixo de `weightNormalizedName` (0.15): mesmo no teto (nome +idêntico ao candidato mais lexicalmente próximo possível), a evidência de similaridade é mais +fraca que uma convenção de nomenclatura confirmada pelo perfil, porque não carrega a mesma +garantia de padrão — é proximidade de string, não regra de linguagem. Valor de partida: 0.12. +Como todo peso deste arquivo, é estimativa a recalibrar com o corpus, não medição. + +## Onde a via de fallback entra em `generateFor` + +`generateFor` já resolve, para a via por afixo, a sequência index → filtro de aridade/chave → +compatibilidade de tipo → ambiguidade → sinais. A via por similaridade entra **só** quando +`index[entity]` vem vazio — nunca quando vem não-vazio e todo mundo é descartado por aridade +ou ausência de chave, porque nesse caso o `Skip` já registrado explica o motivo e uma segunda +tentativa por outro caminho, mirando talvez uma tabela diferente, arrisca confundir "por que +essa tabela foi ignorada" com "por que essa outra apareceu do nada". + +A parte de filtro de aridade/chave/tipo é compartilhada entre as duas vias via um helper +extraído (`resolveTarget`), para não duplicar a lógica que já existe. `buildSignals` passa a +receber o sinal de nome já pronto, montado pelo chamador de acordo com a via — exato/normalizado +para a via por afixo (lógica que sai de dentro da função, mas não muda de comportamento), +`SigNameSimilarity` para a via nova. O resto de `buildSignals` (tipo, ambiguidade, índice, +comentário, not-null, domínio genérico) é idêntico para as duas vias — a via de origem do +sinal de nome não muda nada além de qual primeiro sinal entra. + +## Custo não medido, registrado como risco aceito + +A via por afixo consulta um índice pré-computado (`map[string][]indexedTarget`) — custo +praticamente constante por coluna. A via de similaridade, quando ativada, compara contra +**todas** as tabelas do schema em escopo — custo linear no tamanho do schema, por coluna sem +casamento por afixo. Em schema grande com muitas colunas sem casamento (o padrão que motiva +esta change), o produto desses dois números não foi medido. + +Estimativa de ordem de grandeza, não medição: GitLab (corpus, 1054 tabelas) — se algumas +centenas de colunas caírem na via de fallback, o total fica na casa de dezenas a centenas de +milhares de comparações de trigrama, cada uma da ordem de microssegundos para strings de +poucas dezenas de caracteres. Compatível com os orçamentos de tempo já registrados em +`docs/benchmark/cost.md` (candidatos gerados hoje em ~150-160ms para o schema inteiro), mas +"compatível na estimativa" não é "medido" — `make benchmark` fica como encaminhamento explícito +em `tasks.md`, a rodar antes de qualquer recalibração de limiar. diff --git a/openspec/changes/archive/2026-08-13-similaridade-lexical-candidatos/proposal.md b/openspec/changes/archive/2026-08-13-similaridade-lexical-candidatos/proposal.md new file mode 100644 index 0000000..e3034d6 --- /dev/null +++ b/openspec/changes/archive/2026-08-13-similaridade-lexical-candidatos/proposal.md @@ -0,0 +1,64 @@ +## Why + +O casamento de nome hoje (`internal/infer.generateFor`) só gera candidato quando a forma +normalizada da coluna (`Profile.EntityName`) bate, literalmente ou por plural, com uma das +formas indexadas de alguma tabela (`Profile.TableForms`). Quando não bate, a função não gera +candidato e não registra nada — o único ponto de silêncio que resta no produto, cuja regra 4 +(`openspec/project.md`) é justamente que silêncio nunca é ausência de problema. + +O corpus público já documenta essa lacuna com exemplos reais (`docs/PGFATHOM.md`, "O que a +gap remanescente é"): + +``` +atotramite.tptramite_idkey → tramitetipo abreviado, reordenado +basecalculo.idkey_operador → operadorbasecalculo nomeado pelo papel +ato.atorevogacao_idkey → ato nomeado pelo papel +``` + +Nos três, o perfil stripa o afixo corretamente (a entidade não sai vazia), mas a forma +resultante não é literal nem plural de nenhuma tabela — é abreviação ou reordenação de +caracteres. É sobreposição lexical, não semântica: um cálculo de distância determinístico +sobre as strings resolve os três, sem precisar de modelo de linguagem, sem rede, sem +dependência nova. + +Fica fora de escopo o caso genuinamente semântico, sem sobreposição de caracteres — +`os_servico.resp_tecnico → funcionario.id`, o exemplo que abre o próprio README. Esse caso já +tem via própria no produto: evidência de uso (`internal/sqlprobe`), que prova a relação lendo +o `JOIN` real em vez de adivinhar por proximidade de string. Não é reimplementado aqui. + +## What Changes + +- Novo sinal `SigNameSimilarity` (`internal/model`), emitido quando a coluna casa com uma + tabela por similaridade lexical de trigrama de caractere, em vez de por afixo/plural do + perfil. +- Nova via de geração em `internal/infer.generateFor`, ativada **só** quando o índice do + perfil não encontra nenhuma tabela para a entidade extraída da coluna — nunca quando encontra + e descarta por chave composta ou ausência de chave primária, caso em que o `Skip` já + existente explica o motivo. +- Cálculo de similaridade puro, sem estado, sem I/O: coeficiente de Dice sobre conjuntos de + trigrama de caractere, mesma família de técnica do `pg_trgm`, recalculada em Go — sem + precisar da extensão instalada no banco-alvo. +- Candidato nascido dessa via passa pelo mesmo pipeline de validação de tipo, aridade e + ambiguidade que a via por afixo já usa, e pelo mesmo limiar de pontuação final + (`DefaultMinScore`) que qualquer outro candidato — nenhuma mudança na garantia de "nenhum + falso positivo confirmado" (regra 5): quem confirma continua sendo a validação contra dado, + não o sinal de nome. + +## Capabilities + +- `candidate-generation` — MODIFIED: nova via de casamento, condicionada à ausência de + qualquer casamento por perfil. +- `candidate-scoring` — MODIFIED: novo sinal de nome e sua faixa de peso, mais fraca que o + casamento normalizado por perfil. + +## Impact + +- Sem dependência nova no `go.mod` — cálculo de trigrama é string pura, biblioteca padrão. +- Custo: a via de fallback varre todas as tabelas do schema em escopo para cada coluna sem + casamento por perfil, o que multiplica o número de comparações em relação à via por afixo + (que consulta um índice pré-computado). Cada comparação é barata (strings curtas), mas o + efeito agregado em schema grande (o corpus GitLab tem 1054 tabelas) não foi medido nesta + change — `make benchmark` fica registrado como encaminhamento em `tasks.md`, não bloqueia + esta entrega. +- Nenhuma mudança em `internal/validate`, `internal/stats`, `internal/report` — o candidato + novo é indistinguível dos demais a partir do momento em que sai de `internal/infer`. diff --git a/openspec/changes/archive/2026-08-13-similaridade-lexical-candidatos/specs/candidate-generation/spec.md b/openspec/changes/archive/2026-08-13-similaridade-lexical-candidatos/specs/candidate-generation/spec.md new file mode 100644 index 0000000..aeb7d17 --- /dev/null +++ b/openspec/changes/archive/2026-08-13-similaridade-lexical-candidatos/specs/candidate-generation/spec.md @@ -0,0 +1,55 @@ +## ADDED Requirements + +### Requirement: Similaridade lexical gera candidato quando o perfil não encontra nada + +Quando o casamento pelo conjunto de formas do perfil de nomenclatura ativo não encontrar +nenhuma tabela para a entidade extraída de uma coluna, o sistema SHALL comparar essa entidade +por similaridade lexical de trigrama de caractere contra o nome de cada tabela do escopo, e +SHALL gerar candidato para toda tabela cuja similaridade atingir o limiar mínimo configurado. + +Esta via MUST NOT ser acionada quando o casamento por perfil encontrar ao menos uma tabela, +mesmo que todas sejam descartadas por chave composta ou ausência de chave primária — nesse +caso o motivo do descarte já é registrado pela via existente, e tentar de novo por outro +caminho arrisca confundir por que uma tabela foi ignorada com por que outra apareceu. + +Candidato nascido desta via SHALL passar pelo mesmo filtro de compatibilidade de tipo, pela +mesma regra de ambiguidade de alvo e pelo mesmo limiar de pontuação final que um candidato +nascido do casamento por perfil. + +#### Scenario: Casamento por perfil ausente aciona a via por similaridade + +- **WHEN** a entidade extraída de uma coluna não casa com nenhuma forma de nenhuma tabela do + escopo, e uma tabela do escopo cruza o limiar mínimo de similaridade lexical +- **THEN** um candidato é gerado para essa tabela, carregando o sinal de similaridade lexical + +#### Scenario: Casamento por perfil presente não aciona a via por similaridade + +- **WHEN** a entidade extraída de uma coluna casa com pelo menos uma tabela pelo conjunto de + formas do perfil, mesmo que essa tabela seja descartada por chave composta ou ausência de + chave primária +- **THEN** a via por similaridade lexical não é avaliada para essa coluna + +#### Scenario: Abaixo do limiar de similaridade não gera candidato + +- **WHEN** o casamento por perfil não encontra nenhuma tabela, e nenhuma tabela do escopo + atinge o limiar mínimo de similaridade lexical +- **THEN** nenhum candidato é gerado para essa coluna, como no comportamento anterior a esta + via existir + +#### Scenario: Múltiplas tabelas acima do limiar geram ambiguidade + +- **WHEN** mais de uma tabela do escopo cruza o limiar mínimo de similaridade lexical para a + mesma coluna +- **THEN** um candidato é gerado para cada uma, todos carregando o sinal de alvo ambíguo + +#### Scenario: Tipo incompatível descarta mesmo acima do limiar de similaridade + +- **WHEN** uma tabela cruza o limiar mínimo de similaridade lexical, mas o tipo base da coluna + filha é incompatível com o tipo base da chave primária da tabela +- **THEN** nenhum candidato é gerado para esse par + +#### Scenario: Alvo sem chave primária ou de chave composta é pulado com nota + +- **WHEN** uma tabela cruza o limiar mínimo de similaridade lexical e não tem chave primária, + ou tem chave primária composta +- **THEN** nenhum candidato é gerado para esse par, e o motivo é registrado como pulado diff --git a/openspec/changes/archive/2026-08-13-similaridade-lexical-candidatos/specs/candidate-scoring/spec.md b/openspec/changes/archive/2026-08-13-similaridade-lexical-candidatos/specs/candidate-scoring/spec.md new file mode 100644 index 0000000..897d60f --- /dev/null +++ b/openspec/changes/archive/2026-08-13-similaridade-lexical-candidatos/specs/candidate-scoring/spec.md @@ -0,0 +1,60 @@ +## ADDED Requirements + +### Requirement: Sinal de similaridade lexical pontua abaixo do casamento normalizado por perfil + +O sistema SHALL emitir um sinal de similaridade lexical para candidato nascido da via de +fallback por trigrama, com peso proporcional à similaridade medida, escalado linearmente até +um teto estritamente menor que o peso do sinal de nome normalizado por perfil. + +Mesmo no teto — o par mais lexicalmente próximo possível dentro do limiar — a evidência de +similaridade é mais fraca que uma convenção de nomenclatura confirmada pelo perfil, porque não +carrega a mesma garantia de regra de linguagem: é proximidade de string, não convenção +reconhecida. + +Um candidato SHALL carregar no máximo um sinal de nome — exato, normalizado por perfil, ou de +similaridade lexical —, nunca mais de um, porque as três vias de casamento são mutuamente +exclusivas por construção: a via por similaridade só é avaliada quando as outras duas não +encontraram nenhuma tabela. + +#### Scenario: Similaridade no teto ainda pontua menos que normalizado + +- **WHEN** dois candidatos são idênticos exceto pelo sinal de nome, um com casamento + normalizado por perfil e outro com similaridade lexical no valor máximo possível +- **THEN** o de casamento normalizado por perfil tem score maior ou igual + +#### Scenario: Peso escala com a similaridade medida + +- **WHEN** dois candidatos nascem da via de similaridade lexical, um com similaridade maior + que o outro, ambos acima do limiar mínimo +- **THEN** o de similaridade maior tem peso de sinal de nome estritamente maior + +#### Scenario: Nenhum candidato carrega mais de um sinal de nome + +- **WHEN** um candidato de qualquer origem é inspecionado +- **THEN** ele carrega exatamente um sinal entre nome exato, nome normalizado e similaridade + lexical, nunca dois + +### Requirement: Limiar de geração por similaridade é distinto do limiar de pontuação + +O sistema SHALL manter o limiar mínimo de similaridade lexical — que decide se a via de +fallback gera candidato — configurável separadamente do limiar de pontuação que decide se um +candidato sobrevive até a validação. Os dois SHALL ter valores padrão independentes e SHALL +poder ser ajustados sem afetar um ao outro. + +Um candidato pode cruzar o limiar de similaridade e ainda ser descartado pelo limiar de +pontuação, se os demais sinais forem fracos — o mesmo comportamento que já vale hoje para +casamento normalizado fraco por perfil. + +#### Scenario: Limiares ajustáveis independentemente + +- **WHEN** o usuário fornece um limiar de similaridade diferente do padrão, sem alterar o + limiar de pontuação +- **THEN** o conjunto de candidatos gerados pela via de similaridade muda, e o corte que + decide sobrevivência à validação permanece o mesmo + +#### Scenario: Cruzar o limiar de similaridade não garante sobreviver ao limiar de pontuação + +- **WHEN** um candidato nascido da via de similaridade lexical cruza o limiar mínimo de + similaridade, mas a soma de todos os seus sinais fica abaixo do limiar de pontuação +- **THEN** o candidato é descartado antes da validação, com o motivo registrado, como + qualquer outro candidato abaixo do limiar de pontuação diff --git a/openspec/changes/archive/2026-08-13-similaridade-lexical-candidatos/tasks.md b/openspec/changes/archive/2026-08-13-similaridade-lexical-candidatos/tasks.md new file mode 100644 index 0000000..19e38c0 --- /dev/null +++ b/openspec/changes/archive/2026-08-13-similaridade-lexical-candidatos/tasks.md @@ -0,0 +1,67 @@ +## 1. Modelo e pesos + +- [x] 1.1 `internal/model/candidate.go`: adicionar `SigNameSimilarity` ao bloco de sinais + baseados em nome +- [x] 1.2 `internal/infer/similarity.go` (novo): `TrigramSimilarity(a, b string) float64`, + coeficiente de Dice sobre trigrama com padding de borda, case-insensitive. Exportada + (não `trigramSimilarity`) para seguir o padrão já em uso no pacote — `generate_test.go` + e `score_test.go` só testam por fora (`package infer_test`), nunca white-box. +- [x] 1.3 `internal/infer/similarity_test.go` (novo): idênticas → 1.0, sem sobreposição → 0.0, + string vazia → 0.0, os pares reais do corpus com o valor calculado documentado. + Achado na execução real: `tptramite`/`tramitetipo` = 0.545, `operador`/ + `operadorbasecalculo` = 0.552, `atorevogacao`/`ato` = 0.353 — todos abaixo do limiar de + 0.65 planejado antes de medir. Ver 4 e `design.md` para a correção. +- [x] 1.4 `internal/infer/score.go`: `weightNameSimilarityMax` (0.12), `DefaultMinNameSimilarity` + (0.30, recalibrado — ver acima), `nameSimilarityWeight(score float64) float64` + +## 2. Geração + +- [x] 2.1 `internal/infer/generate.go`: `Options.MinNameSimilarity` + accessor + `minNameSimilarity()`, mesmo padrão de `minScore()`/`smallTableRows()` +- [x] 2.2 `Generate`: `flattenTables` monta lista plana de tabelas do escopo, ao lado do + índice existente +- [x] 2.3 Extraído `resolveKeyTarget` (filtro de aridade/chave, antes inline em `generateFor`) + para reuso pelas duas vias +- [x] 2.4 `generateFor`: via de fallback por similaridade (`resolveBySimilarity`), ativada só + quando `index[entity]` vem vazio. `finalizeMatches` unifica o restante do pipeline + (compatibilidade de tipo, ambiguidade, montagem de sinais) para as duas vias. +- [x] 2.5 `buildSignals`: assinatura passa a receber o sinal de nome já pronto + (`nameSignalFromOrigin`/`nameSignalFromSimilarity`), decisão exato/normalizado saiu da + função para o chamador da via por afixo + +## 3. Testes de não-regressão e do caso novo + +- [x] 3.1 `internal/infer/generate_similarity_test.go` + (`TestAffixMatchSuppressesSimilarityFallback`): casamento por afixo continua idêntico + ao atual, via de similaridade nunca aciona quando o afixo já resolveu +- [x] 3.2 `TestSimilarityFallbackGeneratesWhenAffixFindsNothing`: candidato com + `SigNameSimilarity`, sem `SigExactName`/`SigNormalizedName` +- [x] 3.3 `TestSimilarityBelowCutoffGeneratesNothing`: abaixo do limiar → nenhum candidato +- [x] 3.4 `TestSimilarityFallbackHandlesAmbiguity`: duas tabelas cruzam o limiar para a mesma + coluna → candidato para as duas, ambas com `SigAmbiguousTarget` +- [x] 3.5 `TestSimilarityFallbackReproducesCorpusMiss`: reprodução do padrão + `atotramite.tptramite_idkey → tramitetipo` em formato sintético — usa o sufixo `_id` + reconhecido pelo perfil embarcado em vez de `_idkey`, porque `_idkey` só existe via + detecção de nomenclatura por schema (fora do escopo de um teste de unidade sobre o + perfil embarcado), não no perfil `pt-br` estático + +## 4. Validação + +- [x] 4.1 `make test` — suíte inteira, todos os pacotes, sem falha +- [x] 4.2 `make lint` indisponível neste ambiente (`golangci-lint` não instalado) — rodado + `go vet ./...` como alternativa, limpo. Lint completo fica pendente de ambiente com a + ferramenta instalada. +- [x] 4.3 `make cover` (escopo `internal/infer`, `go test -coverprofile`) — 90% no pacote, todo + código novo (`flattenTables`, `resolveByAffix`, `resolveBySimilarity`, + `finalizeMatches`, `nameSignalFromOrigin`, `nameSignalFromSimilarity`, `buildSignals`, + `TrigramSimilarity`, `nameSimilarityWeight`) em 100%. As duas exceções + (`resolveKeyTarget` 71.4%, branch de coluna de PK ausente; `generateFor` 88.9%) são + ramos defensivos que já existiam sem cobertura antes desta change, preservados tal como + estavam — não uma queda introduzida aqui. Comparação direta antes/depois via `git stash` não + foi possível (hook de proteção de git bloqueia `stash` fora de `/git-commit`/`/git-merge`). + +## 5. Encaminhamento (fora desta entrega) + +- [ ] 5.1 `make benchmark` contra o corpus público, para medir custo real e efeito em recall + antes de qualquer recalibração dos limiares de similaridade e de peso — não bloqueia + esta change (ambiente pode não ter Docker disponível) diff --git a/openspec/changes/fix-promocao-unique-para-pk/.openspec.yaml b/openspec/changes/fix-promocao-unique-para-pk/.openspec.yaml new file mode 100644 index 0000000..a49a59f --- /dev/null +++ b/openspec/changes/fix-promocao-unique-para-pk/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +skip_specs: true diff --git a/openspec/changes/fix-promocao-unique-para-pk/design.md b/openspec/changes/fix-promocao-unique-para-pk/design.md new file mode 100644 index 0000000..2853951 --- /dev/null +++ b/openspec/changes/fix-promocao-unique-para-pk/design.md @@ -0,0 +1,59 @@ +## Por que três passos, não dois + +Os dois vizinhos de `writePromoteUnique` no mesmo arquivo já resolvem "como +promover uma chave sem lock pesado" com dois passos: constrói o índice +`CONCURRENTLY`, promove. Funciona ali porque não existe nenhuma constraint +no caminho — o índice novo nasce livre. + +O nosso caso começa de um lugar diferente: já existe uma `UNIQUE` constraint, +com um índice que já é dela. `USING INDEX` não aceita esse índice porque ele +já está "ocupado". A saída é construir um índice **novo**, autônomo, promovê-lo +(esse sim aceito, porque nasceu livre), e só then descartar a `UNIQUE` +antiga — que nesse ponto já é redundante com a PK nova. O terceiro passo +(`DROP CONSTRAINT`) é o que diferencia este caminho dos outros dois; não é +invenção nova, é a consequência direta de já existir uma constraint prévia. + +## Nome do índice novo + +Mesma convenção que `writeConfirmedPrimaryKey`/`writeSyntheticPrimaryKey` já +usam: `truncateIdent("ux_" + t.Name + "_" + strings.Join(columns, "_"))`. +Reaproveitar em vez de inventar uma segunda convenção — o arquivo já tem uma, +e ela já trata truncamento em nomes acima de `NAMEDATALEN`. + +## Por que as três linhas ficam comentadas + +`CREATE INDEX CONCURRENTLY` não roda dentro de bloco de transação, e o +arquivo `.sql` inteiro é pensado para ser revisado antes de rodar (README, +seção Safety: "Nothing generated is meant to be executed unreviewed"). Os +dois vizinhos já comentam suas três/duas linhas por esse motivo exato — a +única razão de `writePromoteUnique` hoje gerar SQL executável direto é que +ninguém tinha notado que esse caminho também precisa do mesmo cuidado, porque +o bug de `USING INDEX` mascarava o problema antes mesmo de chegar a essa +questão. + +## Texto corrigido, não removido + +Onde hoje diz "at no cost" / "scans nothing" / "without rewriting a row", a +correção não apaga a alegação — troca pela parte que continua verdadeira: o +catálogo já prova a unicidade e a nulidade, então **nenhuma sondagem de +dado** é necessária (isso nunca deixou de ser verdade). O que deixa de ser +dito é que a *execução* da DDL é gratuita — ela builda um índice novo, que +tem custo de I/O mesmo sendo `CONCURRENTLY` (sem lock longo, mas não sem +leitura da tabela inteira). + +## Teste: por que rodar as três linhas separadas, nunca juntas + +`CONCURRENTLY` não pode estar na mesma transação que outro comando — e o +driver (`pgx`) trata múltiplos statements separados por `;` numa única +chamada `Exec` como um bloco implícito em alguns modos. `TestSuggestedIndexesArtifactParsesUnderExplain` +já resolve isso rodando uma linha comentada por vez, com `conn.Exec` +individual; o teste desta change estende a mesma técnica para as três linhas +em sequência, na mesma conexão, terminando com a verificação de estado que já +existia (`pg_constraint` com `contype = 'p'`). + +## Sem delta de spec + +`openspec/specs/structural-audit/spec.md:96` já diz "custo baixo", nunca +"custo zero" — o requisito nunca prometeu o que a implementação prometia. +Não há requisito a adicionar ou modificar; é bug fix puro contra um spec que +já estava certo. Arquivar com `--skip-specs`. diff --git a/openspec/changes/fix-promocao-unique-para-pk/proposal.md b/openspec/changes/fix-promocao-unique-para-pk/proposal.md new file mode 100644 index 0000000..dc777b5 --- /dev/null +++ b/openspec/changes/fix-promocao-unique-para-pk/proposal.md @@ -0,0 +1,57 @@ +## Why + +`TestSuggestedKeysArtifactPromotesLiveUnique` (`internal/cli/audit_efficiency_integration_test.go`) +falha contra Postgres de verdade: a DDL que `writePromoteUnique` gera pra +promover uma `UNIQUE` constraint a chave primária não roda. + +``` +ERROR: index "cadastro_pessoa_cpf_key" is already associated with a +constraint (SQLSTATE 55000) +``` + +`ALTER TABLE ... ADD PRIMARY KEY USING INDEX ` só aceita índice +autônomo — nunca um já associado a uma constraint. O índice de toda `UNIQUE` +declarada já nasce associado a ela, então esse caminho nunca funcionou contra +um banco real. Bug pré-existente, já em `main` antes de qualquer trabalho +desta sessão — achado rodando `make test-integration`, não relacionado à +feature de similaridade lexical. + +`openspec/specs/structural-audit/spec.md:96` já diz o requisito certo — "um +caminho provado pelo catálogo e de custo baixo" — nunca prometeu custo zero. +A implementação é que prometia mais do que o spec ("without rewriting a +row", "at no cost", "scans nothing") em quatro lugares, e por isso escolheu +o único mecanismo que o Postgres rejeita para esse caso. + +## What Changes + +- `writePromoteUnique` (`internal/report/sql.go`) passa a gerar DDL de três + passos, comentada — `CREATE UNIQUE INDEX CONCURRENTLY` (índice novo, + autônomo) → `ADD PRIMARY KEY USING INDEX` (esse sim aceito, porque o + índice é novo) → `DROP CONSTRAINT` da `UNIQUE` antiga. Mesmo padrão que os + dois vizinhos do arquivo (`writeConfirmedPrimaryKey`, + `writeSyntheticPrimaryKey`) já usam para `CREATE INDEX CONCURRENTLY`, + reaproveitando os mesmos helpers de nomenclatura e truncamento. +- Texto corrigido em quatro lugares (`internal/model/schema.go`, + `internal/report/sql.go` — comentário de código e comentário dentro do SQL + gerado —, `internal/audit/audit.go`) para parar de prometer custo zero, + mantendo a promessa que continua verdadeira: o catálogo já prova a chave, + sem sondagem de dado. +- `TestSuggestedKeysArtifactPromotesLiveUnique` reescrito para rodar as três + linhas comentadas em sequência contra o banco, em vez de extrair "linha + executável" (que passa a não existir mais para este achado). + +## Capabilities + +Nenhuma mudança de requisito — `structural-audit` já promete "custo baixo", +não "custo zero". Esta é correção de implementação contra um spec que já +estava certo; `skip_specs: true` no archive. + +## Impact + +- Comportamento visível: o artefato `suggested_keys.sql` para este achado + passa a vir comentado (exige revisão manual antes de rodar), igual aos + outros dois caminhos de promoção de PK do mesmo arquivo. Hoje é o único + que roda direto — e é por isso que quebra. +- Sem dependência nova, sem mudança de contrato em `model.Suggestion`/ + `model.Finding`. +- Sem mudança em `internal/validate`, `internal/stats`. diff --git a/openspec/changes/fix-promocao-unique-para-pk/tasks.md b/openspec/changes/fix-promocao-unique-para-pk/tasks.md new file mode 100644 index 0000000..545ec66 --- /dev/null +++ b/openspec/changes/fix-promocao-unique-para-pk/tasks.md @@ -0,0 +1,45 @@ +## 1. Texto + +- [x] 1.1 `internal/model/schema.go`: comentário de `PromotableUnique` para + de prometer "without rewriting a row" via `USING INDEX` direto +- [x] 1.2 `internal/audit/audit.go`: comentário de `missingPrimaryKeys` e + `Finding.Detail` param de dizer "at no cost"/zero-cost + +## 2. DDL + +- [x] 2.1 `internal/report/sql.go`: `writePromoteUnique` deriva nome de + índice novo via `truncateIdent("ux_" + t.Name + "_" + strings.Join(...))`, + mesma convenção dos vizinhos +- [x] 2.2 Emite três linhas comentadas: `CREATE UNIQUE INDEX CONCURRENTLY`, + `ALTER TABLE ... ADD PRIMARY KEY USING INDEX`, `ALTER TABLE ... DROP + CONSTRAINT` — nessa ordem +- [x] 2.3 Comentário explicativo atualizado (por que três passos, aviso de + truncamento de nome se aplicável), removendo "scans nothing" + +## 3. Teste + +- [x] 3.1 `TestSuggestedKeysArtifactPromotesLiveUnique` reescrito: extrai as + três linhas comentadas, roda cada uma em `conn.Exec` separado (nunca + concatenadas), mantém a verificação final de `pg_constraint` +- [x] 3.2 Não previsto no plano original: `internal/report/sql_test.go` tinha + dois testes (`TestSuggestedKeysPromotesExistingUnique`, + `TestSuggestedKeysConfirmedCompositeUsesTwoStepCommented`) que + codificavam o próprio bug como requisito — um exigia explicitamente + `ADD PRIMARY KEY USING INDEX "cadastro_cpf_key"` sem comentário, o + outro tinha uma exceção nomeada só pra não reclamar dessa linha + descomentada. Nenhum dos dois roda contra Postgres de verdade (são + testes de string sobre o artefato gerado), por isso nunca pegaram o + erro que só a suíte de integração alcança. Reescritos: + `TestSuggestedKeysPromotesExistingUniqueViaThreeStepCommented` agora + exige as três linhas comentadas; a exceção no teste do composto foi + removida. + +## 4. Validação + +- [x] 4.1 `go build ./...` e `go build -tags=integration ./...` — limpos +- [x] 4.2 `make test` (suíte sem Docker) — verde, incluindo os dois testes + corrigidos em `internal/report` +- [x] 4.3 `go vet ./...` e `go vet -tags=integration ./...` — limpos +- [ ] 4.4 `make test-integration` — não executável neste ambiente (sem + Docker); fica para o usuário confirmar no dele, como nos dois fixes + anteriores desta sessão diff --git a/openspec/specs/candidate-generation/spec.md b/openspec/specs/candidate-generation/spec.md index ac8532b..2b82b17 100644 --- a/openspec/specs/candidate-generation/spec.md +++ b/openspec/specs/candidate-generation/spec.md @@ -238,3 +238,57 @@ A regra anterior exigia derivação uniforme em todas as posições, sob o argum - **WHEN** todas as posições casam por nome mas uma delas tem tipo incompatível - **THEN** nenhum candidato é gerado +### Requirement: Similaridade lexical gera candidato quando o perfil não encontra nada + +Quando o casamento pelo conjunto de formas do perfil de nomenclatura ativo não encontrar +nenhuma tabela para a entidade extraída de uma coluna, o sistema SHALL comparar essa entidade +por similaridade lexical de trigrama de caractere contra o nome de cada tabela do escopo, e +SHALL gerar candidato para toda tabela cuja similaridade atingir o limiar mínimo configurado. + +Esta via MUST NOT ser acionada quando o casamento por perfil encontrar ao menos uma tabela, +mesmo que todas sejam descartadas por chave composta ou ausência de chave primária — nesse +caso o motivo do descarte já é registrado pela via existente, e tentar de novo por outro +caminho arrisca confundir por que uma tabela foi ignorada com por que outra apareceu. + +Candidato nascido desta via SHALL passar pelo mesmo filtro de compatibilidade de tipo, pela +mesma regra de ambiguidade de alvo e pelo mesmo limiar de pontuação final que um candidato +nascido do casamento por perfil. + +#### Scenario: Casamento por perfil ausente aciona a via por similaridade + +- **WHEN** a entidade extraída de uma coluna não casa com nenhuma forma de nenhuma tabela do + escopo, e uma tabela do escopo cruza o limiar mínimo de similaridade lexical +- **THEN** um candidato é gerado para essa tabela, carregando o sinal de similaridade lexical + +#### Scenario: Casamento por perfil presente não aciona a via por similaridade + +- **WHEN** a entidade extraída de uma coluna casa com pelo menos uma tabela pelo conjunto de + formas do perfil, mesmo que essa tabela seja descartada por chave composta ou ausência de + chave primária +- **THEN** a via por similaridade lexical não é avaliada para essa coluna + +#### Scenario: Abaixo do limiar de similaridade não gera candidato + +- **WHEN** o casamento por perfil não encontra nenhuma tabela, e nenhuma tabela do escopo + atinge o limiar mínimo de similaridade lexical +- **THEN** nenhum candidato é gerado para essa coluna, como no comportamento anterior a esta + via existir + +#### Scenario: Múltiplas tabelas acima do limiar geram ambiguidade + +- **WHEN** mais de uma tabela do escopo cruza o limiar mínimo de similaridade lexical para a + mesma coluna +- **THEN** um candidato é gerado para cada uma, todos carregando o sinal de alvo ambíguo + +#### Scenario: Tipo incompatível descarta mesmo acima do limiar de similaridade + +- **WHEN** uma tabela cruza o limiar mínimo de similaridade lexical, mas o tipo base da coluna + filha é incompatível com o tipo base da chave primária da tabela +- **THEN** nenhum candidato é gerado para esse par + +#### Scenario: Alvo sem chave primária ou de chave composta é pulado com nota + +- **WHEN** uma tabela cruza o limiar mínimo de similaridade lexical e não tem chave primária, + ou tem chave primária composta +- **THEN** nenhum candidato é gerado para esse par, e o motivo é registrado como pulado + diff --git a/openspec/specs/candidate-scoring/spec.md b/openspec/specs/candidate-scoring/spec.md index 1ec5986..a113a3f 100644 --- a/openspec/specs/candidate-scoring/spec.md +++ b/openspec/specs/candidate-scoring/spec.md @@ -164,3 +164,62 @@ Duas implementações da saturação divergiriam mais cedo ou mais tarde, e o li - **WHEN** sinais negativos acumulados levariam o score abaixo de zero - **THEN** o score recomposto é zero, nunca negativo +### Requirement: Sinal de similaridade lexical pontua abaixo do casamento normalizado por perfil + +O sistema SHALL emitir um sinal de similaridade lexical para candidato nascido da via de +fallback por trigrama, com peso proporcional à similaridade medida, escalado linearmente até +um teto estritamente menor que o peso do sinal de nome normalizado por perfil. + +Mesmo no teto — o par mais lexicalmente próximo possível dentro do limiar — a evidência de +similaridade é mais fraca que uma convenção de nomenclatura confirmada pelo perfil, porque não +carrega a mesma garantia de regra de linguagem: é proximidade de string, não convenção +reconhecida. + +Um candidato SHALL carregar no máximo um sinal de nome — exato, normalizado por perfil, ou de +similaridade lexical —, nunca mais de um, porque as três vias de casamento são mutuamente +exclusivas por construção: a via por similaridade só é avaliada quando as outras duas não +encontraram nenhuma tabela. + +#### Scenario: Similaridade no teto ainda pontua menos que normalizado + +- **WHEN** dois candidatos são idênticos exceto pelo sinal de nome, um com casamento + normalizado por perfil e outro com similaridade lexical no valor máximo possível +- **THEN** o de casamento normalizado por perfil tem score maior ou igual + +#### Scenario: Peso escala com a similaridade medida + +- **WHEN** dois candidatos nascem da via de similaridade lexical, um com similaridade maior + que o outro, ambos acima do limiar mínimo +- **THEN** o de similaridade maior tem peso de sinal de nome estritamente maior + +#### Scenario: Nenhum candidato carrega mais de um sinal de nome + +- **WHEN** um candidato de qualquer origem é inspecionado +- **THEN** ele carrega exatamente um sinal entre nome exato, nome normalizado e similaridade + lexical, nunca dois + +### Requirement: Limiar de geração por similaridade é distinto do limiar de pontuação + +O sistema SHALL manter o limiar mínimo de similaridade lexical — que decide se a via de +fallback gera candidato — configurável separadamente do limiar de pontuação que decide se um +candidato sobrevive até a validação. Os dois SHALL ter valores padrão independentes e SHALL +poder ser ajustados sem afetar um ao outro. + +Um candidato pode cruzar o limiar de similaridade e ainda ser descartado pelo limiar de +pontuação, se os demais sinais forem fracos — o mesmo comportamento que já vale hoje para +casamento normalizado fraco por perfil. + +#### Scenario: Limiares ajustáveis independentemente + +- **WHEN** o usuário fornece um limiar de similaridade diferente do padrão, sem alterar o + limiar de pontuação +- **THEN** o conjunto de candidatos gerados pela via de similaridade muda, e o corte que + decide sobrevivência à validação permanece o mesmo + +#### Scenario: Cruzar o limiar de similaridade não garante sobreviver ao limiar de pontuação + +- **WHEN** um candidato nascido da via de similaridade lexical cruza o limiar mínimo de + similaridade, mas a soma de todos os seus sinais fica abaixo do limiar de pontuação +- **THEN** o candidato é descartado antes da validação, com o motivo registrado, como + qualquer outro candidato abaixo do limiar de pontuação + diff --git a/openspec/specs/structural-audit/spec.md b/openspec/specs/structural-audit/spec.md index 3afc63e..9ba7b64 100644 --- a/openspec/specs/structural-audit/spec.md +++ b/openspec/specs/structural-audit/spec.md @@ -89,3 +89,154 @@ Falha de conexão, de privilégio ou erro interno SHALL sair com o código de fa - **WHEN** a conexão não pode ser estabelecida - **THEN** o código de saída é o de falha e a mensagem vai para stderr +### Requirement: Tabela sem chave primária é reportada + +O comando SHALL reportar toda tabela em escopo que não tenha chave primária, acompanhada da estimativa de linhas, que é o que indica a gravidade. + +Sem PK a tabela não tem identidade de linha: replicação lógica não a cobre e todo `UPDATE`/`DELETE` por linha vira varredura. Quando existe um `UNIQUE` cujas colunas são todas `NOT NULL`, o achado SHALL oferecer a promoção dessa unique a PK, que é um caminho provado pelo catálogo e de custo baixo. + +#### Scenario: Tabela sem PK com unique promovível + +- **WHEN** uma tabela não tem PK mas tem uma constraint `UNIQUE` com todas as colunas `NOT NULL` +- **THEN** o achado aparece com a sugestão de promover essa unique a chave primária + +#### Scenario: Tabela sem PK e sem unique promovível + +- **WHEN** uma tabela não tem PK nem unique promovível +- **THEN** o achado aparece marcado como precisando de sondagem de chave, sem afirmar qual é a chave + +#### Scenario: Tabela com PK não gera achado + +- **WHEN** todas as tabelas do escopo têm chave primária +- **THEN** nenhum achado desse tipo é emitido + +### Requirement: A chave sugerida é confirmada por contagem, nunca por estimativa + +Quando não há unique promovível, o comando MAY sondar a unicidade de um conjunto de colunas para nomear a chave. A sondagem SHALL emitir apenas contagens — nenhum valor de coluna em struct, log, JSON ou erro. Unicidade SHALL ser confirmada apenas em varredura completa da tabela: modo amostrado nunca confirma chave, porque uma duplicata pode estar fora da amostra. + +Um conjunto sondado que estoure o `statement_timeout` SHALL sair como `unverified` e a execução prossegue. Estimativa de `n_distinct` MAY priorizar o que sondar, mas nunca SHALL ser afirmada como chave. + +#### Scenario: Chave composta confirmada por contagem + +- **WHEN** a sondagem completa uma tabela cuja unicidade real é composta e as contagens provam `total = distinct` sem nulos +- **THEN** o achado nomeia a chave composta como confirmada + +#### Scenario: Amostra não confirma chave + +- **WHEN** a tabela é grande e só pôde ser lida por amostra +- **THEN** nenhuma chave sai como confirmada; a chave sai `unverified` e a tabela consta da cobertura + +#### Scenario: Sondagem não vaza valor + +- **WHEN** a sondagem roda contra fixtures com valores plantados +- **THEN** nenhum desses valores aparece na saída em terminal, no JSON ou no log + +### Requirement: Coluna quente sem índice é reportada com o tipo de índice apropriado + +O comando SHALL reportar toda coluna que apareça em predicado de junção ou de filtro no código real — view, função ou `pg_stat_statements` — com recorrência acima do limiar configurável e sem índice que a lidere. O achado SHALL recomendar o método de índice apropriado ao operador observado e ao tipo da coluna. + +A recomendação SHALL ser `btree` por padrão, servindo igualdade e faixa. `GIN` SHALL ser recomendado para contenção em `jsonb`/array e para full-text; `GIN gin_trgm_ops` para `LIKE`/`ILIKE` de infixo quando `pg_trgm` estiver instalada; um método de vizinhança para coluna `vector` quando `pgvector` estiver instalada. Extensão ausente nunca vira erro nem recomendação impossível: degrada para `btree` com nota ou omite o achado. + +#### Scenario: Coluna de junção sem índice + +- **WHEN** uma view cruza repetidamente uma coluna que não lidera nenhum índice +- **THEN** o achado aparece recomendando `btree` sobre essa coluna + +#### Scenario: Contenção em jsonb sem GIN + +- **WHEN** uma função usa `@>` sobre uma coluna `jsonb` sem índice `GIN` +- **THEN** o achado recomenda `GIN` para essa coluna + +#### Scenario: Infixo sem pg_trgm + +- **WHEN** um predicado `LIKE '%x%'` incide sobre uma coluna sem índice e `pg_trgm` não está instalada +- **THEN** o achado recomenda `btree` com a nota de que o infixo pede `pg_trgm`, sem falhar + +#### Scenario: FK sem índice não é duplicada + +- **WHEN** uma coluna já é reportada por `fk_without_index` +- **THEN** ela não gera também um achado de coluna quente + +### Requirement: A leitura de dado da sondagem é opcional e limitada por teto + +A sondagem de chave SHALL rodar automaticamente apenas para tabelas abaixo de um teto de linhas estimadas, configurável. Tabelas acima do teto SHALL constar da cobertura como não sondadas, mantendo a oferta de promoção de unique quando houver. Uma flag SHALL desligar a leitura de dado por inteiro, devolvendo o comando ao comportamento catálogo-puro. + +#### Scenario: Tabela grande não é sondada + +- **WHEN** uma tabela sem PK excede o teto de tamanho +- **THEN** a sondagem não roda contra ela e a cobertura registra a tabela como não sondada + +#### Scenario: Leitura de dado desligada + +- **WHEN** o comando roda com a sondagem desligada +- **THEN** nenhuma transação de leitura de dado é aberta e os achados de PK saem apenas do catálogo + +### Requirement: A resolução interativa de chave ausente é gated por terminal, nunca por flag + +O comando SHALL só oferecer resolução interativa de chave ausente quando stdin e stdout forem ambos um terminal interativo. O comando SHALL NOT introduzir uma flag para ligar ou desligar esse comportamento por si só — ele segue o ambiente de execução. `--no-probe-keys` SHALL desligar a resolução interativa junto com a sondagem automática, porque ambas leem dado da tabela. + +#### Scenario: Saída redirecionada nunca pausa + +- **WHEN** o comando roda com stdout redirecionado para um arquivo ou pipe +- **THEN** nenhuma tabela sem chave confirmada dispara um prompt, e a saída é idêntica à que o comando produziria sem esta capability + +#### Scenario: --no-probe-keys desliga tudo + +- **WHEN** o comando roda com `--no-probe-keys`, mesmo em terminal interativo +- **THEN** nenhuma leitura de dado acontece, incluindo a resolução interativa + +### Requirement: A resolução de chave ausente é uma decisão única por execução, não por tabela + +Quando houver ao menos uma tabela sem chave confirmada e o terminal for interativo, o comando SHALL primeiro avaliar todo o catálogo e só então relatar, uma única vez, quantas tabelas estão pendentes, quantas têm um candidato composto ainda não testado, e o nome de primary key mais comum entre as tabelas do escopo que já têm uma — cada convenção citada acompanhada de exemplos concretos dos objetos que a sustentam. O comando SHALL perguntar no máximo uma vez por execução o que fazer, e a resposta SHALL se aplicar a todas as tabelas pendentes de uma vez, nunca tabela a tabela. + +#### Scenario: Resumo antes da pergunta + +- **WHEN** três tabelas ficam sem chave confirmada, duas delas com candidato composto +- **THEN** o comando relata as três tabelas, os dois candidatos compostos, e a convenção de nome de PK — antes de perguntar qualquer coisa + +### Requirement: A coluna sintética é sempre nomeada pela convenção do schema, nunca digitada + +Quando o operador escolher a recomendação de coluna sintética, o comando SHALL nomeá-la com o nome de primary key mais comum entre as tabelas do escopo que já têm uma chave de coluna única — o mesmo nome que qualquer outra tabela do schema já usa. O comando SHALL NOT aceitar um nome de coluna digitado livremente. Quando nenhum nome puder ser determinado a partir do schema, a opção de coluna sintética SHALL NOT ser oferecida. + +#### Scenario: Coluna sintética segue a convenção + +- **WHEN** o operador escolhe a recomendação de coluna sintética e o schema majoritariamente nomeia a chave primária de um jeito +- **THEN** toda tabela pendente resolvida por essa escolha ganha uma coluna sintética com esse nome, sem sondagem de dado + +#### Scenario: Sem convenção, sem opção de coluna sintética + +- **WHEN** nenhuma tabela do escopo tem chave primária de coluna única o suficiente para tabular uma convenção +- **THEN** o menu não oferece a recomendação de coluna sintética, só o candidato composto (quando existir) e pular + +### Requirement: Composto e sintético são recomendações globais, aplicadas a toda tabela pendente + +Ao escolher a recomendação de chave composta, o comando SHALL testar, por contagem completa, o candidato de cada tabela pendente que tiver um — nunca afirmado sem essa prova. Ao escolher a recomendação de coluna sintética, o comando SHALL aplicá-la a toda tabela pendente, independentemente de ela ter ou não um candidato composto. + +#### Scenario: Chave composta confirmada pela escolha global + +- **WHEN** o operador escolhe a recomendação de chave composta e a sondagem por contagem completa confirma unicidade para uma das tabelas pendentes +- **THEN** o achado dessa tabela é resolvido como chave composta confirmada, com o mesmo veredito `confirmed` que o caminho automático produz + +#### Scenario: Pular não resolve nada + +- **WHEN** o operador responde vazio ou o stdin fecha (EOF) antes de uma resposta válida +- **THEN** todo achado pendente permanece exatamente como o caminho automático o deixou, sem sugestão adicional + +### Requirement: Uma resposta não reconhecida é reportada como inválida e perguntada de novo + +O comando SHALL NOT tratar uma resposta que não corresponda a nenhuma opção oferecida como um pedido para pular. Ele SHALL informar que a resposta foi inválida e perguntar novamente, até receber uma resposta reconhecida ou o stdin fechar. + +#### Scenario: Resposta inválida não pula silenciosamente + +- **WHEN** o operador digita algo que não corresponde a nenhuma opção do menu +- **THEN** o comando informa que a resposta foi inválida e pergunta de novo, em vez de tratar a tabela como pulada + +### Requirement: Coluna sintética nunca é afirmada como confirmada por dado + +Uma sugestão de coluna sintética SHALL NOT carregar um veredito de sondagem: sua correção não depende de nenhum dado existente, só da criação da coluna. O artefato `.sql` gerado para ela SHALL declarar em duas etapas — criação da coluna, depois promoção a chave primária — e SHALL observar que a criação de uma coluna `GENERATED ALWAYS AS IDENTITY` já reescreve a tabela. + +#### Scenario: Artefato de coluna sintética + +- **WHEN** um achado tem uma sugestão de coluna sintética +- **THEN** `suggested_keys.sql` emite a criação da coluna e a promoção a chave primária em duas etapas comentadas, com a ressalva sobre reescrita da tabela + diff --git a/openspec/specs/usage-evidence/spec.md b/openspec/specs/usage-evidence/spec.md index 9cda13c..376f53a 100644 --- a/openspec/specs/usage-evidence/spec.md +++ b/openspec/specs/usage-evidence/spec.md @@ -90,3 +90,29 @@ Candidato nascido ou reforçado por evidência de uso SHALL seguir o mesmo camin - **WHEN** um candidato nasce de junção em view - **THEN** ele entra na validação como qualquer outro e o veredito vem dos dados +### Requirement: O extrator reconhece o operador de um predicado de coluna qualificada + +Além da igualdade entre duas colunas qualificadas, o extrator SHALL reconhecer um predicado cujo lado esquerdo é uma referência de coluna qualificada e classificar o operador — igualdade, comparação de faixa, `LIKE`/`ILIKE`, contenção (`@>`, `<@`, `?`, `?|`, `?&`), full-text (`@@`) e distância de vetor. O lado direito SHALL ser classificado em literal, parâmetro ou referência, e um `LIKE` sobre literal constante SHALL distinguir prefixo de infixo. + +Essa evidência alimenta a recomendação de tipo de índice. A extração de junção existente MUST permanecer inalterada, e predicado que o extrator não reconhece MUST ser ignorado sem erro: a degradação da fase 6 vale igual aqui. + +#### Scenario: Predicado de contenção é classificado + +- **WHEN** uma função contém `t.dados @> '...'::jsonb` sobre uma coluna qualificada +- **THEN** a evidência de predicado registra a coluna resolvida e o operador de contenção + +#### Scenario: LIKE de infixo é distinguido do de prefixo + +- **WHEN** o SQL contém `t.nome LIKE '%x%'` e, em outro ponto, `t.cod LIKE 'x%'` +- **THEN** o primeiro é classificado como infixo e o segundo como prefixo + +#### Scenario: Operador desconhecido não derruba a extração + +- **WHEN** um predicado usa um operador que o extrator não classifica +- **THEN** aquele predicado é ignorado sem erro e a extração das demais fontes segue + +#### Scenario: A junção existente segue intacta + +- **WHEN** uma view cruza duas colunas por igualdade com aliases +- **THEN** a evidência de junção continua sendo extraída como antes, ao lado da nova evidência de predicado +