282 lines
11 KiB
Go
282 lines
11 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))
|
|
}
|
|
|
|
// 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")
|
|
}
|