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 `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 }