textmachine/platform/internal/pgstore/sqlcgate_test.go

252 lines
9.6 KiB
Go

package pgstore
import (
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
)
// sqlcgate: the generated query layer is IN SYNC with the .sql files it was generated from, checked
// on every battery run and without needing sqlc installed.
//
// It exists because "generated code in the tree" has a failure mode that no other gate here reaches:
// somebody edits a statement in `queries/*.sql`, does not re-run the generator, and the build keeps
// executing the OLD SQL while the file everybody reads says something else. `make check` also runs
// `sqlc diff`, which is the authoritative answer, but that needs the tool on the machine — and a
// property this package relies on should not be one that only a fully-equipped host can check.
//
// What it proves: every query in the .sql files has a generated const, every generated const has a
// query, the SQL TEXT of each pair matches once comments and whitespace are normalized away, and the
// PARAMETER ORDER matches. What it does NOT prove: that the rest of the Go around the const — the row
// struct, the Scan order — is what sqlc would emit. That is `sqlc diff`'s half, and it is why both
// exist.
//
// ⚠ The parameter check is not decoration, and text alone genuinely misses its class. Swapping two
// argument NAMES inside a WHERE — `provider = sqlc.arg(subject) and subject = sqlc.arg(provider)` —
// leaves the generated const byte-identical, because both spellings render `$1` and `$2` in the same
// places. What moves is the order of the params struct. Without the check below this gate passed that
// edit while the running code did the opposite of what the .sql file said, which is the exact shape
// it exists to prevent on a host with no sqlc installed. Found by this pack's adversarial pass.
//
// Mutation caught: editing a WHERE clause in queries/*.sql without regenerating; transposing two
// sqlc.arg names without regenerating; adding or renaming a query without regenerating; hand-editing
// the SQL inside a generated const.
func TestEveryGeneratedQueryMatchesItsSourceFile(t *testing.T) {
source := queriesFromSQLFiles(t)
generated := queriesFromGeneratedGo(t)
// A floor, for the same reason sqlgate has one: an extractor that quietly stopped finding
// anything would turn this into a green light over nothing.
if len(source) < 30 {
t.Fatalf("found only %d queries in queries/*.sql: this gate has stopped seeing them", len(source))
}
sourceArgs := argOrderFromSQLFiles(t)
generatedArgs := paramOrderFromGeneratedGo(t)
for name, want := range source {
got, ok := generated[name]
if !ok {
t.Errorf("%s is written in queries/*.sql but no generated const carries it: the generator has not been re-run", name)
continue
}
if got != want {
t.Errorf("%s: the generated SQL is not what the .sql file says.\n .sql file: %s\n generated: %s", name, want, got)
}
// Only queries with a params struct can drift this way: one argument cannot be transposed
// with itself, and sqlc emits no struct for none or one.
if gen, ok := generatedArgs[name]; ok {
if src := sourceArgs[name]; !sameArgOrder(src, gen) {
t.Errorf("%s: the .sql file's arguments are in a different order than the generated params struct — the file says %v, the code passes %v. The SQL text is identical either way, so this is a transposition the running code already has and the .sql file no longer describes.",
name, src, gen)
}
}
}
for name := range generated {
if _, ok := source[name]; !ok {
t.Errorf("%s is in the generated code but in no .sql file: it was renamed or removed without re-running the generator", name)
}
}
t.Logf("%d generated queries match their source", len(source))
}
var sqlNameRe = regexp.MustCompile(`(?m)^--\s*name:\s*(\w+)\s*:(\w+)\s*$`)
// queriesFromSQLFiles reads the hand-written .sql, keyed by query name, with `sqlc.arg(x)` resolved
// to the `$n` the generator would assign: first appearance wins a new number, a repeat of the same
// name reuses it. That is sqlc's own rule, and reproducing it here is what lets the two texts be
// compared at all.
func queriesFromSQLFiles(t *testing.T) map[string]string {
t.Helper()
files, err := filepath.Glob(filepath.Join("queries", "*.sql"))
if err != nil || len(files) == 0 {
t.Fatalf("no query files found: %v", err)
}
out := map[string]string{}
argRe := regexp.MustCompile(`sqlc\.n?arg\(\s*(\w+)\s*\)`)
for _, f := range files {
body, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
text := string(body)
locs := sqlNameRe.FindAllStringSubmatchIndex(text, -1)
for i, loc := range locs {
name := text[loc[2]:loc[3]]
end := len(text)
if i+1 < len(locs) {
end = locs[i+1][0]
}
stmt := text[loc[1]:end]
seen := map[string]int{}
stmt = argRe.ReplaceAllStringFunc(stmt, func(m string) string {
arg := argRe.FindStringSubmatch(m)[1]
if n, ok := seen[arg]; ok {
return "$" + strconv.Itoa(n)
}
n := len(seen) + 1
seen[arg] = n
return "$" + strconv.Itoa(n)
})
if _, dup := out[name]; dup {
t.Errorf("%s is defined twice across queries/*.sql", name)
}
out[name] = normalizeSQL(stmt)
}
}
return out
}
// queriesFromGeneratedGo reads the SQL back out of the generated consts. The `-- name:` header sqlc
// keeps inside each const is what identifies them, so no mapping table is needed.
func queriesFromGeneratedGo(t *testing.T) map[string]string {
t.Helper()
files, err := filepath.Glob("*.sql.go")
if err != nil {
t.Fatal(err)
}
out := map[string]string{}
for _, f := range files {
body, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
for _, raw := range constLiterals(string(body)) {
m := sqlNameRe.FindStringSubmatch(raw)
if m == nil {
continue
}
out[m[1]] = normalizeSQL(sqlNameRe.ReplaceAllString(raw, ""))
}
}
if len(out) == 0 {
t.Fatal("no generated SQL consts were found: this gate no longer matches the package it reads")
}
return out
}
// constLiterals returns the raw string literal of every `const x = ` + "`" + `...` + "`" + ` declaration.
//
// ⚠ Only CONST declarations, and the predecessor of this function is why. It split the whole file on
// backticks and took the odd halves, which is correct only while the number of backticks outside the
// literals is even — and generated files carry the .sql prose as Go comments, where backticked
// identifiers are this repository's house style. One added backtick in a comment shifted every pair
// and the gate reported that the generator had not been re-run, on a tree where it had. A false red
// that accuses the wrong thing is worse than no gate, because the next reader believes it.
func constLiterals(src string) []string {
var out []string
for _, m := range constLitRe.FindAllStringSubmatch(src, -1) {
out = append(out, m[1])
}
return out
}
var constLitRe = regexp.MustCompile("(?s)const\\s+\\w+\\s*=\\s*`([^`]*)`")
// normalizeSQL removes what the generator is free to change — comments, the trailing semicolon and
// every difference of whitespace — and leaves what it is not: the statement.
func normalizeSQL(s string) string {
var kept []string
for _, line := range strings.Split(s, "\n") {
if t := strings.TrimSpace(line); strings.HasPrefix(t, "--") {
continue
}
kept = append(kept, line)
}
joined := strings.Join(strings.Fields(strings.Join(kept, "\n")), " ")
return strings.TrimSuffix(strings.TrimSpace(joined), ";")
}
// argOrderFromSQLFiles is the order in which each query's sqlc.arg names FIRST appear, which is the
// order sqlc assigns `$n` and therefore the order of the params struct it emits.
func argOrderFromSQLFiles(t *testing.T) map[string][]string {
t.Helper()
files, _ := filepath.Glob(filepath.Join("queries", "*.sql"))
argRe := regexp.MustCompile(`sqlc\.n?arg\(\s*(\w+)\s*\)`)
out := map[string][]string{}
for _, f := range files {
body, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
text := string(body)
locs := sqlNameRe.FindAllStringSubmatchIndex(text, -1)
for i, loc := range locs {
end := len(text)
if i+1 < len(locs) {
end = locs[i+1][0]
}
var order []string
seen := map[string]bool{}
for _, m := range argRe.FindAllStringSubmatch(text[loc[1]:end], -1) {
if !seen[m[1]] {
seen[m[1]] = true
order = append(order, m[1])
}
}
out[text[loc[2]:loc[3]]] = order
}
}
return out
}
// paramOrderFromGeneratedGo is the field order of each generated `<Name>Params` struct.
func paramOrderFromGeneratedGo(t *testing.T) map[string][]string {
t.Helper()
files, _ := filepath.Glob("*.sql.go")
structRe := regexp.MustCompile(`(?s)type (\w+)Params struct \{(.*?)\n\}`)
fieldRe := regexp.MustCompile(`(?m)^\t(\w+)\s`)
out := map[string][]string{}
for _, f := range files {
body, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
for _, m := range structRe.FindAllStringSubmatch(string(body), -1) {
var fields []string
for _, fm := range fieldRe.FindAllStringSubmatch(m[2], -1) {
fields = append(fields, fm[1])
}
out[m[1]] = fields
}
}
return out
}
// sameArgOrder compares a .sql argument list with a generated field list. The two spell one name
// differently — `ip_prefix` against `IpPrefix`, `token_sha256` against `TokenSha256` — and
// reproducing sqlc's exact capitalisation here would be a second copy of a rule this package does not
// own, so the comparison folds case and underscores away instead.
func sameArgOrder(sqlArgs, goFields []string) bool {
if len(sqlArgs) != len(goFields) {
return false
}
fold := func(s string) string { return strings.ToLower(strings.ReplaceAll(s, "_", "")) }
for i := range sqlArgs {
if fold(sqlArgs[i]) != fold(goFields[i]) {
return false
}
}
return true
}