Skip to content

Commit d6d8f6c

Browse files
committed
Add regenerate-explain tool for multi-statement test cases
This Go tool regenerates explain.txt files by splitting each query.sql into individual statements and running EXPLAIN AST via clickhouse local. For tests with multiple statements: - First statement → explain.txt - Subsequent statements → explain_N.txt (N = 2, 3, ...) Usage: ./regenerate-explain -test <name> # Process single test ./regenerate-explain # Process all tests ./regenerate-explain -dry-run # Preview statements
1 parent 8567118 commit d6d8f6c

File tree

1 file changed

+231
-0
lines changed

1 file changed

+231
-0
lines changed

cmd/regenerate-explain/main.go

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"flag"
6+
"fmt"
7+
"os"
8+
"os/exec"
9+
"path/filepath"
10+
"strings"
11+
)
12+
13+
func main() {
14+
testName := flag.String("test", "", "Single test directory name to process (if empty, process all)")
15+
clickhouseBin := flag.String("bin", "./clickhouse", "Path to ClickHouse binary")
16+
dryRun := flag.Bool("dry-run", false, "Print statements without executing")
17+
flag.Parse()
18+
19+
// Check if clickhouse binary exists
20+
if !*dryRun {
21+
if _, err := os.Stat(*clickhouseBin); os.IsNotExist(err) {
22+
fmt.Fprintf(os.Stderr, "ClickHouse binary not found at %s\n", *clickhouseBin)
23+
fmt.Fprintf(os.Stderr, "Run: ./scripts/clickhouse.sh download\n")
24+
os.Exit(1)
25+
}
26+
}
27+
28+
testdataDir := "parser/testdata"
29+
30+
if *testName != "" {
31+
// Process single test
32+
if err := processTest(filepath.Join(testdataDir, *testName), *clickhouseBin, *dryRun); err != nil {
33+
fmt.Fprintf(os.Stderr, "Error processing %s: %v\n", *testName, err)
34+
os.Exit(1)
35+
}
36+
return
37+
}
38+
39+
// Process all tests
40+
entries, err := os.ReadDir(testdataDir)
41+
if err != nil {
42+
fmt.Fprintf(os.Stderr, "Error reading testdata: %v\n", err)
43+
os.Exit(1)
44+
}
45+
46+
var errors []string
47+
var processed, skipped int
48+
for _, entry := range entries {
49+
if !entry.IsDir() {
50+
continue
51+
}
52+
testDir := filepath.Join(testdataDir, entry.Name())
53+
if err := processTest(testDir, *clickhouseBin, *dryRun); err != nil {
54+
if strings.Contains(err.Error(), "no statements found") {
55+
skipped++
56+
continue
57+
}
58+
errors = append(errors, fmt.Sprintf("%s: %v", entry.Name(), err))
59+
} else {
60+
processed++
61+
}
62+
}
63+
64+
fmt.Printf("\nProcessed: %d, Skipped: %d, Errors: %d\n", processed, skipped, len(errors))
65+
if len(errors) > 0 {
66+
fmt.Fprintf(os.Stderr, "\nErrors:\n")
67+
for _, e := range errors {
68+
fmt.Fprintf(os.Stderr, " %s\n", e)
69+
}
70+
os.Exit(1)
71+
}
72+
}
73+
74+
func processTest(testDir, clickhouseBin string, dryRun bool) error {
75+
queryPath := filepath.Join(testDir, "query.sql")
76+
queryBytes, err := os.ReadFile(queryPath)
77+
if err != nil {
78+
return fmt.Errorf("reading query.sql: %w", err)
79+
}
80+
81+
statements := splitStatements(string(queryBytes))
82+
if len(statements) == 0 {
83+
return fmt.Errorf("no statements found")
84+
}
85+
86+
fmt.Printf("Processing %s (%d statements)\n", filepath.Base(testDir), len(statements))
87+
88+
for i, stmt := range statements {
89+
if dryRun {
90+
fmt.Printf(" [%d] %s\n", i+1, truncate(stmt, 80))
91+
continue
92+
}
93+
94+
explain, err := explainAST(clickhouseBin, stmt)
95+
if err != nil {
96+
fmt.Printf(" [%d] ERROR: %v\n", i+1, err)
97+
// Skip statements that fail - they might be intentionally invalid
98+
continue
99+
}
100+
101+
// Determine output filename
102+
var outputPath string
103+
if i == 0 {
104+
outputPath = filepath.Join(testDir, "explain.txt")
105+
} else {
106+
outputPath = filepath.Join(testDir, fmt.Sprintf("explain_%d.txt", i+1))
107+
}
108+
109+
if err := os.WriteFile(outputPath, []byte(explain+"\n"), 0644); err != nil {
110+
return fmt.Errorf("writing %s: %w", outputPath, err)
111+
}
112+
fmt.Printf(" [%d] -> %s\n", i+1, filepath.Base(outputPath))
113+
}
114+
115+
return nil
116+
}
117+
118+
// splitStatements splits SQL content into individual statements.
119+
// It handles:
120+
// - Comments (-- line comments)
121+
// - Multi-line statements
122+
// - Multiple statements separated by semicolons
123+
func splitStatements(content string) []string {
124+
var statements []string
125+
var current strings.Builder
126+
127+
lines := strings.Split(content, "\n")
128+
for _, line := range lines {
129+
trimmed := strings.TrimSpace(line)
130+
131+
// Skip empty lines and full-line comments
132+
if trimmed == "" || strings.HasPrefix(trimmed, "--") {
133+
continue
134+
}
135+
136+
// Remove inline comments (-- comment at end of line)
137+
// But be careful about comments inside strings
138+
if idx := findCommentStart(trimmed); idx >= 0 {
139+
trimmed = strings.TrimSpace(trimmed[:idx])
140+
if trimmed == "" {
141+
continue
142+
}
143+
}
144+
145+
// Add to current statement
146+
if current.Len() > 0 {
147+
current.WriteString(" ")
148+
}
149+
current.WriteString(trimmed)
150+
151+
// Check if statement is complete (ends with ;)
152+
if strings.HasSuffix(trimmed, ";") {
153+
stmt := strings.TrimSpace(current.String())
154+
// Remove trailing semicolon for EXPLAIN AST
155+
stmt = strings.TrimSuffix(stmt, ";")
156+
if stmt != "" {
157+
statements = append(statements, stmt)
158+
}
159+
current.Reset()
160+
}
161+
}
162+
163+
// Handle statement without trailing semicolon
164+
if current.Len() > 0 {
165+
stmt := strings.TrimSpace(current.String())
166+
stmt = strings.TrimSuffix(stmt, ";")
167+
if stmt != "" {
168+
statements = append(statements, stmt)
169+
}
170+
}
171+
172+
return statements
173+
}
174+
175+
// findCommentStart finds the position of -- comment that's not inside a string
176+
func findCommentStart(line string) int {
177+
inString := false
178+
var stringChar byte
179+
for i := 0; i < len(line); i++ {
180+
c := line[i]
181+
if inString {
182+
if c == '\\' && i+1 < len(line) {
183+
i++ // Skip escaped character
184+
continue
185+
}
186+
if c == stringChar {
187+
inString = false
188+
}
189+
} else {
190+
if c == '\'' || c == '"' || c == '`' {
191+
inString = true
192+
stringChar = c
193+
} else if c == '-' && i+1 < len(line) && line[i+1] == '-' {
194+
// Check if this looks like a comment (followed by space or end of line)
195+
if i+2 >= len(line) || line[i+2] == ' ' || line[i+2] == '\t' {
196+
return i
197+
}
198+
}
199+
}
200+
}
201+
return -1
202+
}
203+
204+
// explainAST runs EXPLAIN AST on the statement using clickhouse local
205+
func explainAST(clickhouseBin, stmt string) (string, error) {
206+
query := fmt.Sprintf("EXPLAIN AST %s", stmt)
207+
cmd := exec.Command(clickhouseBin, "local", "--query", query)
208+
209+
var stdout, stderr bytes.Buffer
210+
cmd.Stdout = &stdout
211+
cmd.Stderr = &stderr
212+
213+
if err := cmd.Run(); err != nil {
214+
errMsg := strings.TrimSpace(stderr.String())
215+
if errMsg == "" {
216+
errMsg = err.Error()
217+
}
218+
return "", fmt.Errorf("%s", errMsg)
219+
}
220+
221+
return strings.TrimSpace(stdout.String()), nil
222+
}
223+
224+
func truncate(s string, n int) string {
225+
s = strings.ReplaceAll(s, "\n", " ")
226+
s = strings.Join(strings.Fields(s), " ") // Normalize whitespace
227+
if len(s) <= n {
228+
return s
229+
}
230+
return s[:n-3] + "..."
231+
}

0 commit comments

Comments
 (0)