textmachine/platform/internal/pgstore/sqlgate_test.go

290 lines
12 KiB
Go

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))
}
// newExceptionCounter makes the tally of how many sites each entry of `unresolvable` covered in ONE
// extraction. Zero means an exception standing over nothing; more than one means it is covering a
// statement it does not describe.
//
// ⚠ PER EXTRACTION, and it was package-level until this pack: `collectSQL` is called by more than
// one test in this package now, and a counter shared across them read the SECOND caller's first
// visit to a site as the FIRST caller's second — so whichever test ran later failed with "holds a
// second statement this gate cannot read" about a function that holds exactly one. Found by this
// pack's own mutation harness, whose clean copies went red where the tree it copied was green: a
// defect of the pack, and the harness earning its keep.
func newExceptionCounter() map[string]int { return 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)
}
usedException := newExceptionCounter()
// 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")
}