Skip to content

Commit 16401b6

Browse files
committed
feat: switched to typed structs for EXPLAIN output & prepared for ANALYZE support
1 parent da10192 commit 16401b6

5 files changed

Lines changed: 292 additions & 78 deletions

File tree

regresql/analyze_types.go

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
package regresql
2+
3+
import "math"
4+
5+
type (
6+
// ExplainOutput is the top-level structure from EXPLAIN (FORMAT JSON)
7+
ExplainOutput struct {
8+
Plan PlanNode `json:"Plan"`
9+
Planning BufferStats `json:"Planning,omitempty"`
10+
PlanningTime float64 `json:"Planning Time,omitempty"`
11+
ExecutionTime float64 `json:"Execution Time,omitempty"`
12+
}
13+
14+
// PlanNode represents a node in the query execution plan
15+
PlanNode struct {
16+
// Core fields (always present)
17+
NodeType string `json:"Node Type"`
18+
StartupCost float64 `json:"Startup Cost"`
19+
TotalCost float64 `json:"Total Cost"`
20+
PlanRows float64 `json:"Plan Rows"`
21+
PlanWidth int `json:"Plan Width"`
22+
ParallelAware bool `json:"Parallel Aware,omitempty"`
23+
AsyncCapable bool `json:"Async Capable,omitempty"`
24+
25+
// Relationship to parent
26+
ParentRelationship string `json:"Parent Relationship,omitempty"`
27+
28+
// Scan node fields
29+
RelationName string `json:"Relation Name,omitempty"`
30+
Alias string `json:"Alias,omitempty"`
31+
ScanDirection string `json:"Scan Direction,omitempty"`
32+
IndexName string `json:"Index Name,omitempty"`
33+
34+
// Conditions
35+
IndexCond string `json:"Index Cond,omitempty"`
36+
Filter string `json:"Filter,omitempty"`
37+
RecheckCond string `json:"Recheck Cond,omitempty"`
38+
MergeCond string `json:"Merge Cond,omitempty"`
39+
HashCond string `json:"Hash Cond,omitempty"`
40+
JoinFilter string `json:"Join Filter,omitempty"`
41+
42+
// Join fields
43+
JoinType string `json:"Join Type,omitempty"`
44+
InnerUnique bool `json:"Inner Unique,omitempty"`
45+
46+
// Sort fields
47+
SortKey []string `json:"Sort Key,omitempty"`
48+
SortMethod string `json:"Sort Method,omitempty"`
49+
SortSpaceUsed int64 `json:"Sort Space Used,omitempty"`
50+
SortSpaceType string `json:"Sort Space Type,omitempty"`
51+
52+
// Parallel/Gather fields
53+
WorkersPlanned int `json:"Workers Planned,omitempty"`
54+
WorkersLaunched int `json:"Workers Launched,omitempty"`
55+
SingleCopy bool `json:"Single Copy,omitempty"`
56+
57+
// Bitmap scan fields
58+
ExactHeapBlocks int64 `json:"Exact Heap Blocks,omitempty"`
59+
LossyHeapBlocks int64 `json:"Lossy Heap Blocks,omitempty"`
60+
HeapFetches int64 `json:"Heap Fetches,omitempty"`
61+
62+
// Append fields
63+
SubplansRemoved int `json:"Subplans Removed,omitempty"`
64+
65+
// Row removal stats
66+
RowsRemovedByFilter int64 `json:"Rows Removed by Filter,omitempty"`
67+
RowsRemovedByIndexRecheck int64 `json:"Rows Removed by Index Recheck,omitempty"`
68+
69+
// ANALYZE fields (only present with ANALYZE true)
70+
ActualStartupTime float64 `json:"Actual Startup Time,omitempty"`
71+
ActualTotalTime float64 `json:"Actual Total Time,omitempty"`
72+
ActualRows int64 `json:"Actual Rows,omitempty"`
73+
ActualLoops int64 `json:"Actual Loops,omitempty"`
74+
75+
// BUFFERS fields (only present with BUFFERS true)
76+
SharedHitBlocks int64 `json:"Shared Hit Blocks,omitempty"`
77+
SharedReadBlocks int64 `json:"Shared Read Blocks,omitempty"`
78+
SharedDirtiedBlocks int64 `json:"Shared Dirtied Blocks,omitempty"`
79+
SharedWrittenBlocks int64 `json:"Shared Written Blocks,omitempty"`
80+
LocalHitBlocks int64 `json:"Local Hit Blocks,omitempty"`
81+
LocalReadBlocks int64 `json:"Local Read Blocks,omitempty"`
82+
LocalDirtiedBlocks int64 `json:"Local Dirtied Blocks,omitempty"`
83+
LocalWrittenBlocks int64 `json:"Local Written Blocks,omitempty"`
84+
TempReadBlocks int64 `json:"Temp Read Blocks,omitempty"`
85+
TempWrittenBlocks int64 `json:"Temp Written Blocks,omitempty"`
86+
IOReadTime float64 `json:"I/O Read Time,omitempty"`
87+
IOWriteTime float64 `json:"I/O Write Time,omitempty"`
88+
TempIOReadTime float64 `json:"Temp I/O Read Time,omitempty"`
89+
TempIOWriteTime float64 `json:"Temp I/O Write Time,omitempty"`
90+
91+
// Child plans
92+
Plans []PlanNode `json:"Plans,omitempty"`
93+
}
94+
95+
// BufferStats holds buffer I/O statistics from EXPLAIN BUFFERS
96+
BufferStats struct {
97+
SharedHitBlocks int64 `json:"Shared Hit Blocks,omitempty"`
98+
SharedReadBlocks int64 `json:"Shared Read Blocks,omitempty"`
99+
SharedDirtiedBlocks int64 `json:"Shared Dirtied Blocks,omitempty"`
100+
SharedWrittenBlocks int64 `json:"Shared Written Blocks,omitempty"`
101+
LocalHitBlocks int64 `json:"Local Hit Blocks,omitempty"`
102+
LocalReadBlocks int64 `json:"Local Read Blocks,omitempty"`
103+
LocalDirtiedBlocks int64 `json:"Local Dirtied Blocks,omitempty"`
104+
LocalWrittenBlocks int64 `json:"Local Written Blocks,omitempty"`
105+
TempReadBlocks int64 `json:"Temp Read Blocks,omitempty"`
106+
TempWrittenBlocks int64 `json:"Temp Written Blocks,omitempty"`
107+
IOReadTime float64 `json:"I/O Read Time,omitempty"`
108+
IOWriteTime float64 `json:"I/O Write Time,omitempty"`
109+
TempIOReadTime float64 `json:"Temp I/O Read Time,omitempty"`
110+
TempIOWriteTime float64 `json:"Temp I/O Write Time,omitempty"`
111+
}
112+
113+
// RowEstimate compares planner estimate vs actual rows
114+
RowEstimate struct {
115+
NodeType string `json:"node_type"`
116+
RelationName string `json:"relation_name,omitempty"`
117+
PlanRows float64 `json:"plan_rows"`
118+
ActualRows int64 `json:"actual_rows"`
119+
ActualLoops int64 `json:"actual_loops"`
120+
Ratio float64 `json:"ratio"`
121+
}
122+
123+
// RowEstimateAnalysis holds all row estimate comparisons
124+
RowEstimateAnalysis struct {
125+
Estimates []RowEstimate `json:"estimates"`
126+
WorstOver *RowEstimate `json:"worst_overestimate,omitempty"`
127+
WorstUnder *RowEstimate `json:"worst_underestimate,omitempty"`
128+
}
129+
)
130+
131+
// GetBufferStats returns buffer statistics for a plan node
132+
func (n *PlanNode) GetBufferStats() BufferStats {
133+
return BufferStats{
134+
SharedHitBlocks: n.SharedHitBlocks,
135+
SharedReadBlocks: n.SharedReadBlocks,
136+
SharedDirtiedBlocks: n.SharedDirtiedBlocks,
137+
SharedWrittenBlocks: n.SharedWrittenBlocks,
138+
LocalHitBlocks: n.LocalHitBlocks,
139+
LocalReadBlocks: n.LocalReadBlocks,
140+
LocalDirtiedBlocks: n.LocalDirtiedBlocks,
141+
LocalWrittenBlocks: n.LocalWrittenBlocks,
142+
TempReadBlocks: n.TempReadBlocks,
143+
TempWrittenBlocks: n.TempWrittenBlocks,
144+
IOReadTime: n.IOReadTime,
145+
IOWriteTime: n.IOWriteTime,
146+
TempIOReadTime: n.TempIOReadTime,
147+
TempIOWriteTime: n.TempIOWriteTime,
148+
}
149+
}
150+
151+
// CompareRowEstimates walks the plan tree and compares Plan Rows vs Actual Rows
152+
func (e *ExplainOutput) CompareRowEstimates() *RowEstimateAnalysis {
153+
analysis := &RowEstimateAnalysis{}
154+
collectNodeRowEstimates(&e.Plan, analysis)
155+
156+
for i := range analysis.Estimates {
157+
est := &analysis.Estimates[i]
158+
if est.Ratio > 1 {
159+
if analysis.WorstUnder == nil || est.Ratio > analysis.WorstUnder.Ratio {
160+
analysis.WorstUnder = est
161+
}
162+
} else if est.Ratio < 1 && est.Ratio > 0 {
163+
if analysis.WorstOver == nil || est.Ratio < analysis.WorstOver.Ratio {
164+
analysis.WorstOver = est
165+
}
166+
}
167+
}
168+
169+
return analysis
170+
}
171+
172+
func collectNodeRowEstimates(node *PlanNode, analysis *RowEstimateAnalysis) {
173+
if node.ActualLoops > 0 {
174+
ratio := 0.0
175+
if node.PlanRows > 0 {
176+
ratio = float64(node.ActualRows) / node.PlanRows
177+
} else if node.ActualRows > 0 {
178+
ratio = math.Inf(1)
179+
}
180+
181+
est := RowEstimate{
182+
NodeType: node.NodeType,
183+
RelationName: node.RelationName,
184+
PlanRows: node.PlanRows,
185+
ActualRows: node.ActualRows,
186+
ActualLoops: node.ActualLoops,
187+
Ratio: ratio,
188+
}
189+
analysis.Estimates = append(analysis.Estimates, est)
190+
}
191+
192+
for i := range node.Plans {
193+
collectNodeRowEstimates(&node.Plans[i], analysis)
194+
}
195+
}
196+
197+
func toInt64(v any) int64 {
198+
switch val := v.(type) {
199+
case float64:
200+
return int64(val)
201+
case int64:
202+
return val
203+
case int:
204+
return int64(val)
205+
default:
206+
return 0
207+
}
208+
}

regresql/baseline.go

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ func getBaselinePath(q *Query, baselineDir string, bindingName string) string {
3939
}
4040

4141
// ExecuteExplain runs EXPLAIN (FORMAT JSON) for a query and returns the parsed plan
42-
func ExecuteExplain(q Querier, query string, args ...any) (map[string]any, error) {
42+
func ExecuteExplain(q Querier, query string, args ...any) (*ExplainOutput, error) {
4343
explainQuery := fmt.Sprintf("EXPLAIN (FORMAT JSON, ANALYZE false, VERBOSE false, COSTS true, BUFFERS false) %s", query)
4444

4545
rows, err := q.Query(explainQuery, args...)
@@ -55,16 +55,16 @@ func ExecuteExplain(q Querier, query string, args ...any) (map[string]any, error
5555
}
5656
}
5757

58-
var plan []map[string]any
59-
if err := json.Unmarshal([]byte(jsonPlan), &plan); err != nil {
58+
var plans []ExplainOutput
59+
if err := json.Unmarshal([]byte(jsonPlan), &plans); err != nil {
6060
return nil, fmt.Errorf("failed to parse EXPLAIN JSON: %w", err)
6161
}
6262

63-
if len(plan) == 0 {
63+
if len(plans) == 0 {
6464
return nil, fmt.Errorf("empty EXPLAIN result")
6565
}
6666

67-
return plan[0], nil
67+
return &plans[0], nil
6868
}
6969

7070
func (q *Query) CreateBaseline(baselineDir string, planDir string, db *sql.DB) error {
@@ -91,7 +91,7 @@ func (q *Query) CreateBaseline(baselineDir string, planDir string, db *sql.DB) e
9191

9292
for i, baseline := range baselines {
9393
baselinePath := getBaselinePath(q, baselineDir, plan.Names[i])
94-
var fullPlan map[string]any
94+
var fullPlan *ExplainOutput
9595
if i < len(fullPlans) {
9696
fullPlan = fullPlans[i]
9797
}
@@ -103,16 +103,10 @@ func (q *Query) CreateBaseline(baselineDir string, planDir string, db *sql.DB) e
103103
return nil
104104
}
105105

106-
func writeBaselineFile(queryName, baselinePath string, filteredPlan map[string]any, fullExplainPlan map[string]any) error {
106+
func writeBaselineFile(queryName, baselinePath string, filteredPlan map[string]any, fullExplainPlan *ExplainOutput) error {
107107
var planSignature *PlanSignature
108108
if fullExplainPlan != nil {
109-
sig, err := ExtractPlanSignature(fullExplainPlan)
110-
if err != nil {
111-
// Log warning but don't fail - plan signature is optional
112-
fmt.Printf(" Warning: failed to extract plan signature for %s: %v\n", queryName, err)
113-
} else {
114-
planSignature = sig
115-
}
109+
planSignature = ExtractPlanSignatureFromNode(&fullExplainPlan.Plan)
116110
}
117111

118112
baseline := Baseline{

regresql/library.go

Lines changed: 22 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ func (p *Plan) CompareCostsData(db *sql.DB, baselines []Baseline, thresholdPerce
9797
continue
9898
}
9999

100-
var explainPlan map[string]any
100+
var explainPlan *ExplainOutput
101101
var err error
102102
if len(p.Query.Args) == 0 {
103103
explainPlan, err = ExecuteExplain(db, p.Query.OrdinalQuery)
@@ -110,13 +110,7 @@ func (p *Plan) CompareCostsData(db *sql.DB, baselines []Baseline, thresholdPerce
110110
continue
111111
}
112112

113-
actualCost := 0.0
114-
if planData, ok := explainPlan["Plan"].(map[string]any); ok {
115-
if cost, ok := planData["Total Cost"]; ok {
116-
actualCost = toFloat64(cost)
117-
}
118-
}
119-
113+
actualCost := explainPlan.Plan.TotalCost
120114
baselineCost := toFloat64(baselines[i].Plan["total_cost"])
121115
passed, percentIncrease := CompareCost(actualCost, baselineCost, thresholdPercent)
122116

@@ -129,39 +123,33 @@ func (p *Plan) CompareCostsData(db *sql.DB, baselines []Baseline, thresholdPerce
129123
}
130124

131125
if baselines[i].PlanSignature != nil {
132-
currentSig, err := ExtractPlanSignature(explainPlan)
133-
if err == nil {
134-
result.PlanChanged = HasPlanChanged(baselines[i].PlanSignature, currentSig)
135-
result.PlanRegressions = DetectPlanRegressions(baselines[i].PlanSignature, currentSig)
136-
137-
for _, regression := range result.PlanRegressions {
138-
if regression.Severity == "critical" {
139-
result.Passed = false
140-
break
141-
}
126+
currentSig := ExtractPlanSignatureFromNode(&explainPlan.Plan)
127+
result.PlanChanged = HasPlanChanged(baselines[i].PlanSignature, currentSig)
128+
result.PlanRegressions = DetectPlanRegressions(baselines[i].PlanSignature, currentSig)
129+
130+
for _, regression := range result.PlanRegressions {
131+
if regression.Severity == "critical" {
132+
result.Passed = false
133+
break
142134
}
143135
}
144136
}
145137

146138
// Detect quality issues (works even without baseline)
147-
if explainPlan != nil {
148-
currentSig, err := ExtractPlanSignature(explainPlan)
149-
if err == nil {
150-
opts := p.Query.GetRegressQLOptions()
151-
ignoredTables := GetIgnoredSeqScanTables()
152-
result.PlanWarnings = DetectPlanQualityIssues(currentSig, opts, ignoredTables)
153-
}
154-
}
139+
currentSig := ExtractPlanSignatureFromNode(&explainPlan.Plan)
140+
opts := p.Query.GetRegressQLOptions()
141+
ignoredTables := GetIgnoredSeqScanTables()
142+
result.PlanWarnings = DetectPlanQualityIssues(currentSig, opts, ignoredTables)
155143

156144
results[i] = result
157145
}
158146

159147
return results
160148
}
161149

162-
func (p *Plan) CreateBaselines(db *sql.DB) ([]Baseline, []map[string]any, error) {
150+
func (p *Plan) CreateBaselines(db *sql.DB) ([]Baseline, []*ExplainOutput, error) {
163151
baselines := make([]Baseline, len(p.Names))
164-
fullPlans := make([]map[string]any, len(p.Names))
152+
fullPlans := make([]*ExplainOutput, len(p.Names))
165153

166154
for i := range p.Names {
167155
baseline, fullPlan, err := p.createSingleBaseline(db, i)
@@ -175,8 +163,8 @@ func (p *Plan) CreateBaselines(db *sql.DB) ([]Baseline, []map[string]any, error)
175163
return baselines, fullPlans, nil
176164
}
177165

178-
func (p *Plan) createSingleBaseline(db *sql.DB, index int) (Baseline, map[string]any, error) {
179-
var explainPlan map[string]any
166+
func (p *Plan) createSingleBaseline(db *sql.DB, index int) (Baseline, *ExplainOutput, error) {
167+
var explainPlan *ExplainOutput
180168
var err error
181169

182170
if len(p.Query.Args) == 0 {
@@ -189,17 +177,10 @@ func (p *Plan) createSingleBaseline(db *sql.DB, index int) (Baseline, map[string
189177
return Baseline{}, nil, fmt.Errorf("failed to create baseline for %s: %w", p.Names[index], err)
190178
}
191179

192-
filteredPlan := make(map[string]any)
193-
if planData, ok := explainPlan["Plan"].(map[string]any); ok {
194-
if startupCost, ok := planData["Startup Cost"]; ok {
195-
filteredPlan["startup_cost"] = startupCost
196-
}
197-
if totalCost, ok := planData["Total Cost"]; ok {
198-
filteredPlan["total_cost"] = totalCost
199-
}
200-
if planRows, ok := planData["Plan Rows"]; ok {
201-
filteredPlan["plan_rows"] = planRows
202-
}
180+
filteredPlan := map[string]any{
181+
"startup_cost": explainPlan.Plan.StartupCost,
182+
"total_cost": explainPlan.Plan.TotalCost,
183+
"plan_rows": explainPlan.Plan.PlanRows,
203184
}
204185

205186
return Baseline{Query: p.Query.Name, Plan: filteredPlan}, explainPlan, nil

0 commit comments

Comments
 (0)