Skip to content

Commit 3c9e25f

Browse files
kyleconroyclaude
andcommitted
Add golden AST files for regression testing
- Create cmd/regenerate-ast to generate golden AST JSON files - Add golden/ast/stmt_NNNN.json structure for each statement - Add -check-ast flag to parser tests to verify AST output - Apply to 00002_system_numbers as proof of concept - Remove old ast.json format Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 544c6b2 commit 3c9e25f

15 files changed

Lines changed: 619 additions & 0 deletions

File tree

cmd/regenerate-ast/main.go

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"flag"
7+
"fmt"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
12+
"github.com/sqlc-dev/doubleclick/parser"
13+
)
14+
15+
func main() {
16+
testName := flag.String("test", "", "Single test directory name to process (if empty, process all)")
17+
dryRun := flag.Bool("dry-run", false, "Print what would be done without making changes")
18+
flag.Parse()
19+
20+
testdataDir := "parser/testdata"
21+
22+
if *testName != "" {
23+
// Process single test
24+
if err := processTest(filepath.Join(testdataDir, *testName), *dryRun); err != nil {
25+
fmt.Fprintf(os.Stderr, "Error processing %s: %v\n", *testName, err)
26+
os.Exit(1)
27+
}
28+
return
29+
}
30+
31+
// Process all tests
32+
entries, err := os.ReadDir(testdataDir)
33+
if err != nil {
34+
fmt.Fprintf(os.Stderr, "Error reading testdata: %v\n", err)
35+
os.Exit(1)
36+
}
37+
38+
var processed, skipped, errors int
39+
for _, entry := range entries {
40+
if !entry.IsDir() {
41+
continue
42+
}
43+
testDir := filepath.Join(testdataDir, entry.Name())
44+
if err := processTest(testDir, *dryRun); err != nil {
45+
if strings.Contains(err.Error(), "no statements found") {
46+
skipped++
47+
} else {
48+
fmt.Fprintf(os.Stderr, "Error processing %s: %v\n", entry.Name(), err)
49+
errors++
50+
}
51+
} else {
52+
processed++
53+
}
54+
}
55+
56+
fmt.Printf("\nProcessed: %d, Skipped: %d, Errors: %d\n", processed, skipped, errors)
57+
if errors > 0 {
58+
os.Exit(1)
59+
}
60+
}
61+
62+
func processTest(testDir string, dryRun bool) error {
63+
queryPath := filepath.Join(testDir, "query.sql")
64+
queryBytes, err := os.ReadFile(queryPath)
65+
if err != nil {
66+
return fmt.Errorf("reading query.sql: %w", err)
67+
}
68+
69+
statements := splitStatements(string(queryBytes))
70+
if len(statements) == 0 {
71+
return fmt.Errorf("no statements found")
72+
}
73+
74+
testName := filepath.Base(testDir)
75+
goldenDir := filepath.Join(testDir, "golden", "ast")
76+
77+
if dryRun {
78+
fmt.Printf("Would process %s (%d statements) -> %s/\n", testName, len(statements), goldenDir)
79+
for i, stmt := range statements {
80+
fmt.Printf(" [%d] %s -> stmt_%04d.json\n", i+1, truncate(stmt, 60), i+1)
81+
}
82+
return nil
83+
}
84+
85+
// Create golden/ast directory
86+
if err := os.MkdirAll(goldenDir, 0755); err != nil {
87+
return fmt.Errorf("creating golden directory: %w", err)
88+
}
89+
90+
var stmtErrors []string
91+
for i, stmt := range statements {
92+
stmtNum := i + 1
93+
94+
// Parse the statement
95+
stmts, parseErr := parser.Parse(context.Background(), strings.NewReader(stmt))
96+
if len(stmts) == 0 {
97+
stmtErrors = append(stmtErrors, fmt.Sprintf("stmt %d: parse error: %v", stmtNum, parseErr))
98+
continue
99+
}
100+
101+
// Marshal to pretty JSON
102+
jsonBytes, err := json.MarshalIndent(stmts[0], "", " ")
103+
if err != nil {
104+
stmtErrors = append(stmtErrors, fmt.Sprintf("stmt %d: json marshal error: %v", stmtNum, err))
105+
continue
106+
}
107+
108+
// Write to golden file
109+
outputPath := filepath.Join(goldenDir, fmt.Sprintf("stmt_%04d.json", stmtNum))
110+
if err := os.WriteFile(outputPath, append(jsonBytes, '\n'), 0644); err != nil {
111+
return fmt.Errorf("writing %s: %w", outputPath, err)
112+
}
113+
}
114+
115+
// Print summary
116+
if len(stmtErrors) > 0 {
117+
fmt.Printf("%s: %d stmts, %d errors\n", testName, len(statements), len(stmtErrors))
118+
for _, e := range stmtErrors {
119+
fmt.Printf(" %s\n", e)
120+
}
121+
} else {
122+
fmt.Printf("%s: %d stmts OK\n", testName, len(statements))
123+
}
124+
125+
return nil
126+
}
127+
128+
// splitStatements splits SQL content into individual statements.
129+
func splitStatements(content string) []string {
130+
var statements []string
131+
var current strings.Builder
132+
133+
lines := strings.Split(content, "\n")
134+
for _, line := range lines {
135+
trimmed := strings.TrimSpace(line)
136+
137+
// Skip empty lines and full-line comments
138+
if trimmed == "" || strings.HasPrefix(trimmed, "--") {
139+
continue
140+
}
141+
142+
// Remove inline comments
143+
if idx := findCommentStart(trimmed); idx >= 0 {
144+
trimmed = strings.TrimSpace(trimmed[:idx])
145+
if trimmed == "" {
146+
continue
147+
}
148+
}
149+
150+
if current.Len() > 0 {
151+
current.WriteString(" ")
152+
}
153+
current.WriteString(trimmed)
154+
155+
if strings.HasSuffix(trimmed, ";") {
156+
stmt := strings.TrimSpace(current.String())
157+
if stmt != "" && stmt != ";" {
158+
statements = append(statements, stmt)
159+
}
160+
current.Reset()
161+
}
162+
}
163+
164+
if current.Len() > 0 {
165+
stmt := strings.TrimSpace(current.String())
166+
if stmt != "" {
167+
statements = append(statements, stmt)
168+
}
169+
}
170+
171+
return statements
172+
}
173+
174+
func findCommentStart(line string) int {
175+
inString := false
176+
var stringChar byte
177+
for i := 0; i < len(line); i++ {
178+
c := line[i]
179+
if inString {
180+
if c == '\\' && i+1 < len(line) {
181+
i++
182+
continue
183+
}
184+
if c == stringChar {
185+
inString = false
186+
}
187+
} else {
188+
if c == '\'' || c == '"' || c == '`' {
189+
inString = true
190+
stringChar = c
191+
} else if c == '-' && i+1 < len(line) && line[i+1] == '-' {
192+
if i+2 >= len(line) || line[i+2] == ' ' || line[i+2] == '\t' {
193+
return i
194+
}
195+
}
196+
}
197+
}
198+
return -1
199+
}
200+
201+
func truncate(s string, n int) string {
202+
s = strings.ReplaceAll(s, "\n", " ")
203+
s = strings.Join(strings.Fields(s), " ")
204+
if len(s) <= n {
205+
return s
206+
}
207+
return s[:n-3] + "..."
208+
}

parser/parser_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ import (
1818
// Use with: go test ./parser -check-explain -v
1919
var checkExplain = flag.Bool("check-explain", false, "Run skipped explain_todo tests to see which ones now pass")
2020

21+
// checkAST enables AST golden file verification.
22+
// Use with: go test ./parser -check-ast -v
23+
var checkAST = flag.Bool("check-ast", false, "Verify AST output matches golden files")
24+
2125
// testMetadata holds optional metadata for a test case
2226
type testMetadata struct {
2327
ExplainTodo map[string]bool `json:"explain_todo,omitempty"` // map of stmtN -> true to skip specific statements
@@ -332,6 +336,24 @@ func TestParser(t *testing.T) {
332336
}
333337
}
334338

339+
// Check AST golden file if -check-ast is enabled
340+
if *checkAST {
341+
astGoldenPath := filepath.Join(testDir, "golden", "ast", fmt.Sprintf("stmt_%04d.json", stmtIndex))
342+
if expectedASTBytes, err := os.ReadFile(astGoldenPath); err == nil {
343+
// Marshal actual AST to JSON
344+
actualASTBytes, err := json.MarshalIndent(stmts[0], "", " ")
345+
if err != nil {
346+
t.Errorf("Failed to marshal AST to JSON: %v", err)
347+
} else {
348+
expectedAST := strings.TrimSpace(string(expectedASTBytes))
349+
actualAST := strings.TrimSpace(string(actualASTBytes))
350+
if expectedAST != actualAST {
351+
t.Errorf("AST mismatch for %s\nExpected:\n%s\n\nGot:\n%s", astGoldenPath, expectedAST, actualAST)
352+
}
353+
}
354+
}
355+
}
356+
335357
})
336358
}
337359
})

parser/testdata/00002_system_numbers/ast.json renamed to parser/testdata/00002_system_numbers/golden/ast/stmt_0001.json

File renamed without changes.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"selects": [
3+
{
4+
"columns": [
5+
{}
6+
],
7+
"from": {
8+
"tables": [
9+
{
10+
"table": {
11+
"table": {
12+
"database": "system",
13+
"table": "numbers"
14+
}
15+
}
16+
}
17+
]
18+
},
19+
"limit": {
20+
"type": "Integer",
21+
"value": 3
22+
}
23+
}
24+
]
25+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"selects": [
3+
{
4+
"columns": [
5+
{
6+
"parts": [
7+
"sys_num",
8+
"number"
9+
]
10+
}
11+
],
12+
"from": {
13+
"tables": [
14+
{
15+
"table": {
16+
"table": {
17+
"database": "system",
18+
"table": "numbers"
19+
},
20+
"alias": "sys_num"
21+
}
22+
}
23+
]
24+
},
25+
"where": {
26+
"left": {
27+
"parts": [
28+
"number"
29+
]
30+
},
31+
"op": "\u003e",
32+
"right": {
33+
"type": "Integer",
34+
"value": 2
35+
}
36+
},
37+
"limit": {
38+
"type": "Integer",
39+
"value": 2
40+
}
41+
}
42+
]
43+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
{
2+
"selects": [
3+
{
4+
"columns": [
5+
{
6+
"parts": [
7+
"number"
8+
]
9+
}
10+
],
11+
"from": {
12+
"tables": [
13+
{
14+
"table": {
15+
"table": {
16+
"database": "system",
17+
"table": "numbers"
18+
}
19+
}
20+
}
21+
]
22+
},
23+
"where": {
24+
"left": {
25+
"parts": [
26+
"number"
27+
]
28+
},
29+
"op": "\u003e=",
30+
"right": {
31+
"type": "Integer",
32+
"value": 5
33+
}
34+
},
35+
"limit": {
36+
"type": "Integer",
37+
"value": 2
38+
}
39+
}
40+
]
41+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"selects": [
3+
{
4+
"columns": [
5+
{}
6+
],
7+
"from": {
8+
"tables": [
9+
{
10+
"table": {
11+
"table": {
12+
"database": "system",
13+
"table": "numbers"
14+
}
15+
}
16+
}
17+
]
18+
},
19+
"where": {
20+
"left": {
21+
"parts": [
22+
"number"
23+
]
24+
},
25+
"op": "==",
26+
"right": {
27+
"type": "Integer",
28+
"value": 7
29+
}
30+
},
31+
"limit": {
32+
"type": "Integer",
33+
"value": 1
34+
}
35+
}
36+
]
37+
}

0 commit comments

Comments
 (0)