252 lines
9.6 KiB
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
|
|
}
|