package pgstore import ( "fmt" "go/ast" "go/parser" "go/token" "path/filepath" "slices" "strconv" "strings" "testing" ) // sqlgate: every SQL statement this package can execute is PARSED BY POSTGRES against the migrated // schema, on every battery run. // // It exists because of a class this zone has been bitten by twice in one pack — // `column r.stop_for_signing does not exist` and `column chapters_before does not exist`, both found // at RUNTIME, both in the read model — and because of what the obvious answer to it cannot reach. // sqlc checks a query against the schema at generate time and is a good tool, but by construction it // only sees SQL written as a whole literal in a .sql file: two thirds of the statements here are // assembled from shared fragments (one projection of a book feeding four paths, so that the card, // the library page and the intake receipt cannot drift apart), and that is precisely where both // runtime errors happened. This gate gets the FINAL string, so the assembly is invisible to it. // // It answers, as a standing property rather than as an audit somebody once ran, the question the // fix-list asked out loud: is there a query in this package that nothing ever exercises. Every one // of them is exercised HERE, whether or not a behavioural test reaches it. // // What it does NOT prove: that a statement does the right thing. It proves that every column, table // and function it names exists in the schema this build migrates to, and that Postgres can plan it. // // Mutation caught: renaming a column in a migration without changing the SQL that reads it; adding a // query whose SQL this extractor cannot resolve (that is a failure, not a skip — see resolve). func TestEverySQLStatementParsesAgainstTheMigratedSchema(t *testing.T) { stmts := collectSQL(t) // A floor on the extractor itself. Without it, an extractor that silently stopped finding call // sites would turn this gate into a green light over nothing — the same shape as a battery that // skips two hundred tests without saying so. if len(stmts) < 140 { t.Fatalf("the extractor found %d statements: it has stopped seeing this package, and a gate that checks nothing passes", len(stmts)) } s, ctx := testDB(t) for _, st := range stmts { // GENERIC_PLAN rather than PREPARE: it plans with the parameters left unknown, which is what // lets a statement be checked without inventing values for `$1` — and inventing them is how a // gate starts asserting about types nobody wrote down. if _, err := s.pool.Exec(ctx, "explain (generic_plan) "+st.sql); err != nil { t.Errorf("%s: Postgres cannot plan this statement against the migrated schema: %v\n%s", st.where, err, indent(st.sql)) } } t.Logf("%d statements planned against the schema", len(stmts)) } // usedException counts how many sites each entry of `unresolvable` covered on this run. Zero means // an exception standing over nothing; more than one means it is covering a statement it does not // describe. var usedException = map[string]int{} // statement is one SQL string this package can execute, and where it comes from. type statement struct { where string sql string } // sqlCalls are the methods whose second argument is SQL, and the package's own two helpers that take // SQL as a parameter. The helpers are here rather than excused: `queryRuns` and `bump` are how three // statements each reach the driver, and a gate that skipped them would skip the reconciler's own // list — the query the sweep lives on. var sqlCalls = map[string]int{ "Query": 1, "QueryRow": 1, "Exec": 1, "SendBatch": 1, "queryRuns": 1, "bump": 2, } // heads are the fragments a helper prepends to the SQL it is handed. `queryRuns` receives a WHERE // tail and nothing else; without the head it would not parse at all. var heads = map[string]string{"queryRuns": runColumns} // unresolvable is the one call site whose SQL genuinely cannot be folded from source, with the value // it takes at runtime. It is a TABLE and not a skip: an entry has to be written by hand, so a second // such site is somebody's deliberate decision rather than a hole that opened quietly. // // `Ready` interpolates goose's own table name through pgx.Identifier.Sanitize — the migration // library owns that name and it is not ours to hard-code anywhere else. // // ⚠ Keyed by the enclosing FUNCTION and not by a line number: a line number moves whenever somebody // edits the file above it, and a different statement then inherits the entry and is checked against // text that is not its own. An entry covers exactly ONE site — a second unfoldable statement in the // same function is reported rather than silently checked against the first one's string. var unresolvable = map[string]string{ "Ready": `select coalesce(max(version_id), 0) from "goose_db_version" where is_applied`, } // collectSQL folds every SQL string in the package's non-test source. // // Folding rather than executing: the statements are literals, concatenations of literals, and named // constants — the whole assembly is decided at compile time, which is exactly why a static gate can // see all of it while a runtime one would only see what the tests happened to run. func collectSQL(t *testing.T) []statement { t.Helper() sources, err := filepath.Glob("*.go") if err != nil { t.Fatal(err) } // Every entry of the exception table has to be USED. An entry for a function that no longer has // unfoldable SQL is an exception standing over a statement nobody checks any more. t.Cleanup(func() { for name := range unresolvable { if usedException[name] == 0 { t.Errorf("`unresolvable` still excuses %s, which no longer needs it: the exception now covers nothing and hides whatever moves under it", name) } } }) fset := token.NewFileSet() var files []*ast.File for _, path := range sources { if strings.HasSuffix(path, "_test.go") { continue } f, err := parser.ParseFile(fset, path, nil, 0) if err != nil { t.Fatalf("%s: %v", path, err) } files = append(files, f) } // ⚠ TOP-LEVEL declarations only. `collectConsts` walks whatever it is given, and given a whole // file it descends into every function body — where `q` is the name of a dozen different // statements. The first version of this gate did exactly that, so a query could be checked // against another query's text and pass. Found by the gate's own first run. pkgConst := map[string]ast.Expr{} for _, f := range files { for _, decl := range f.Decls { if g, ok := decl.(*ast.GenDecl); ok { collectConsts(g, pkgConst) } } } var out []statement helperCalls := map[string]int{} helperBodies := 0 for _, f := range files { for _, decl := range f.Decls { fn, ok := decl.(*ast.FuncDecl) if !ok || fn.Body == nil { continue } // Constants declared INSIDE the function shadow nothing and collide with everything: `q` is // the name of a dozen different statements in this package, so the scope has to be the // function rather than the file. scope := map[string]ast.Expr{} maps := []map[string]ast.Expr{scope, pkgConst} collectConsts(fn, scope) // The BODY of a helper that takes SQL as a parameter is not a site of its own: what it // executes is whatever its callers hand it, and those call sites are checked above. Counted // rather than merely skipped — see the assertion in the caller. if _, isHelper := sqlCalls[fn.Name.Name]; isHelper { helperBodies++ continue } ast.Inspect(fn, func(n ast.Node) bool { call, ok := n.(*ast.CallExpr) if !ok { return true } name := calleeName(call) at, ok := sqlCalls[name] if !ok || at >= len(call.Args) { return true } where := shortPos(fset, call.Pos()) sql, ok := resolve(call.Args[at], maps) if !ok { if known, listed := unresolvable[fn.Name.Name]; listed { usedException[fn.Name.Name]++ if usedException[fn.Name.Name] > 1 { t.Errorf("%s: %s holds a second statement this gate cannot read, and the entry in `unresolvable` describes the first: checking it against that string would prove nothing about it", where, fn.Name.Name) return true } out = append(out, statement{where: where, sql: known}) return true } // NOT skipped. A statement this gate cannot read is a statement nothing checks, and // the whole point is that there is no such thing by accident. t.Errorf("%s: the SQL handed to %s is not a constant expression, so nothing can check it against the schema; make it one, or list it in `unresolvable` with the string it takes", where, name) return true } if _, isHelper := sqlCalls[name]; isHelper && name != "Query" && name != "QueryRow" && name != "Exec" && name != "SendBatch" { helperCalls[name]++ } out = append(out, statement{where: where, sql: heads[name] + sql}) return true }) } } // Every helper whose body was skipped must have had its call sites found instead, or the skip is // the hole it was written to avoid. for _, name := range []string{"queryRuns", "bump"} { if helperCalls[name] == 0 { t.Errorf("no call site of %s was resolved, and its body is skipped: those statements are checked by nothing", name) } } if helperBodies == 0 { t.Error("no helper body was skipped: this gate no longer matches the package it reads") } slices.SortFunc(out, func(a, b statement) int { return strings.Compare(a.where, b.where) }) return out } // collectConsts records every string const and var whose value is a constant expression. func collectConsts(n ast.Node, into map[string]ast.Expr) { ast.Inspect(n, func(n ast.Node) bool { decl, ok := n.(*ast.GenDecl) if !ok || (decl.Tok != token.CONST && decl.Tok != token.VAR) { return true } for _, spec := range decl.Specs { vs, ok := spec.(*ast.ValueSpec) if !ok { continue } for i, name := range vs.Names { if i < len(vs.Values) { into[name.Name] = vs.Values[i] } } } return true }) } // resolve folds a string expression into its value. func resolve(e ast.Expr, scopes []map[string]ast.Expr) (string, bool) { switch v := e.(type) { case *ast.BasicLit: if v.Kind != token.STRING { return "", false } s, err := strconv.Unquote(v.Value) return s, err == nil case *ast.ParenExpr: return resolve(v.X, scopes) case *ast.BinaryExpr: if v.Op != token.ADD { return "", false } l, okl := resolve(v.X, scopes) r, okr := resolve(v.Y, scopes) return l + r, okl && okr case *ast.Ident: for _, scope := range scopes { if def, ok := scope[v.Name]; ok { return resolve(def, scopes) } } return "", false } return "", false } // calleeName is the identifier a call names, whether it is a method or a plain function. func calleeName(call *ast.CallExpr) string { switch fn := call.Fun.(type) { case *ast.SelectorExpr: return fn.Sel.Name case *ast.Ident: return fn.Name } return "" } func shortPos(fset *token.FileSet, p token.Pos) string { pos := fset.Position(p) return fmt.Sprintf("%s:%d", filepath.Base(pos.Filename), pos.Line) } func indent(sql string) string { return "\t" + strings.ReplaceAll(strings.TrimSpace(sql), "\n", "\n\t") }