Skip to main content

nexql_tools/
plan.rs

1// SPDX-License-Identifier: GPL-3.0-only
2// Copyright (C) 2026 NexQL-OSS Team
3
4//! EXPLAIN JSON plan metrics — port of `QueryPerformanceAnalyzer.extractPlanMetrics`.
5
6use serde_json::{Value, json};
7
8/// Extract plan metrics + recommendations from EXPLAIN (FORMAT JSON) output.
9pub fn extract_plan_metrics(explain_plan: &Value) -> Option<Value> {
10    let plan_root = resolve_plan_root(explain_plan)?;
11    let plan_node = plan_root.get("Plan")?;
12
13    let mut sequential_scans = 0u64;
14    let mut index_scans = 0u64;
15    let mut lossy_bitmap_scans = 0u64;
16    let mut spilled_to_disk = 0u64;
17    let mut estimate_mismatches_over_10x = 0u64;
18    let mut function_scans = 0u64;
19    let mut cte_scans = 0u64;
20    let mut subquery_scans = 0u64;
21    let mut bottlenecks: Vec<String> = Vec::new();
22
23    analyze_plan_node(
24        plan_node,
25        &mut sequential_scans,
26        &mut index_scans,
27        &mut lossy_bitmap_scans,
28        &mut spilled_to_disk,
29        &mut estimate_mismatches_over_10x,
30        &mut function_scans,
31        &mut cte_scans,
32        &mut subquery_scans,
33        &mut bottlenecks,
34    );
35
36    let total_cost = plan_node
37        .get("Total Cost")
38        .and_then(|v| v.as_f64())
39        .unwrap_or(0.0);
40    let planning_time = plan_root
41        .get("Planning Time")
42        .and_then(|v| v.as_f64())
43        .unwrap_or(0.0);
44    let execution_time = plan_root
45        .get("Execution Time")
46        .and_then(|v| v.as_f64())
47        .unwrap_or(0.0);
48
49    let buffer_stats = plan_root.get("Buffers").map(|buffers| {
50        let hits = buffers
51            .get("Shared Hit Blocks")
52            .and_then(|v| v.as_u64())
53            .unwrap_or(0);
54        let reads = buffers
55            .get("Shared Read Blocks")
56            .and_then(|v| v.as_u64())
57            .unwrap_or(0);
58        let total = hits + reads;
59        let hit_ratio = if total > 0 {
60            Some(((total - reads) as f64 / total as f64) * 100.0)
61        } else {
62            None
63        };
64        json!({
65            "bufferHits": hits,
66            "bufferReads": reads,
67            "hitRatio": hit_ratio,
68        })
69    });
70
71    let mut recommendations = Vec::new();
72    if sequential_scans > 0 && index_scans == 0 {
73        recommendations.push("Consider adding indexes on frequently filtered columns".to_owned());
74    }
75    if total_cost > 10_000.0 {
76        recommendations.push(
77            "Query planning cost is high; consider simplifying the query or analyzing table statistics"
78                .to_owned(),
79        );
80    }
81    if let Some(ref bs) = buffer_stats
82        && let Some(ratio) = bs.get("hitRatio").and_then(|v| v.as_f64())
83        && ratio < 80.0
84    {
85        recommendations.push(
86            "Low buffer hit ratio; consider increasing work_mem or improving indexes"
87                .to_owned(),
88        );
89    }
90    if let Some(first) = bottlenecks.first() {
91        recommendations.push(format!("Review bottlenecks: {first}"));
92    }
93    if estimate_mismatches_over_10x > 0 {
94        recommendations.push(
95            "Severe row estimate mismatch (>10x) detected. Run ANALYZE and review join/filter selectivity."
96                .to_owned(),
97        );
98    }
99    if lossy_bitmap_scans > 0 {
100        recommendations.push(
101            "Lossy bitmap heap scan detected. Consider more selective indexes or reducing bitmap recheck cost."
102                .to_owned(),
103        );
104    }
105    if spilled_to_disk > 0 {
106        recommendations.push(
107            "Plan node spilled to disk. Consider increasing work_mem for sorts/hashes.".to_owned(),
108        );
109    }
110
111    Some(json!({
112        "totalCost": total_cost,
113        "planningTime": planning_time,
114        "executionTime": execution_time,
115        "sequentialScans": sequential_scans,
116        "indexScans": index_scans,
117        "bufferStats": buffer_stats,
118        "bottlenecks": bottlenecks,
119        "recommendations": recommendations,
120        "lossyBitmapScans": lossy_bitmap_scans,
121        "spilledToDisk": spilled_to_disk,
122        "estimateMismatchesOver10x": estimate_mismatches_over_10x,
123        "functionScans": function_scans,
124        "cteScans": cte_scans,
125        "subqueryScans": subquery_scans,
126    }))
127}
128
129fn resolve_plan_root(explain_plan: &Value) -> Option<&Value> {
130    if explain_plan.get("Plan").is_some() {
131        return Some(explain_plan);
132    }
133    if let Some(v) = explain_plan
134        .as_array()
135        .and_then(|a| a.first())
136        .filter(|v| v.get("Plan").is_some())
137    {
138        return Some(v);
139    }
140    // EXPLAIN rows: [{ "QUERY PLAN": [ { Plan: ... } ] }] or [{ "QUERY PLAN": { Plan } }]
141    if let Some(qp) = explain_plan
142        .as_array()
143        .and_then(|a| a.first())
144        .and_then(|r| r.get("QUERY PLAN"))
145    {
146        if qp.get("Plan").is_some() {
147            return Some(qp);
148        }
149        if let Some(inner) = qp.as_array().and_then(|a| a.first())
150            && inner.get("Plan").is_some()
151        {
152            return Some(inner);
153        }
154    }
155    None
156}
157
158#[allow(clippy::too_many_arguments)]
159fn analyze_plan_node(
160    node: &Value,
161    sequential_scans: &mut u64,
162    index_scans: &mut u64,
163    lossy_bitmap_scans: &mut u64,
164    spilled_to_disk: &mut u64,
165    estimate_mismatches_over_10x: &mut u64,
166    function_scans: &mut u64,
167    cte_scans: &mut u64,
168    subquery_scans: &mut u64,
169    bottlenecks: &mut Vec<String>,
170) {
171    let node_type = node.get("Node Type").and_then(|v| v.as_str()).unwrap_or("");
172    let actual_rows = node
173        .get("Actual Rows")
174        .and_then(|v| v.as_f64())
175        .unwrap_or(0.0);
176    let plan_rows = node
177        .get("Plan Rows")
178        .and_then(|v| v.as_f64())
179        .unwrap_or(0.0);
180    let actual_time = node
181        .get("Actual Total Time")
182        .and_then(|v| v.as_f64())
183        .unwrap_or(0.0);
184
185    if node_type.contains("Seq Scan") {
186        *sequential_scans += 1;
187    } else if node_type.contains("Index Scan") {
188        *index_scans += 1;
189    }
190    if node_type.contains("Function Scan") {
191        *function_scans += 1;
192        let fname = node
193            .get("Function Name")
194            .and_then(|v| v.as_str())
195            .map(|s| format!(" {s}"))
196            .unwrap_or_default();
197        bottlenecks.push(format!("Function scan{fname} observed in plan"));
198    }
199    if node_type.contains("CTE Scan") {
200        *cte_scans += 1;
201        let cte = node
202            .get("CTE Name")
203            .and_then(|v| v.as_str())
204            .map(|s| format!(" {s}"))
205            .unwrap_or_default();
206        bottlenecks.push(format!("CTE scan{cte} observed in plan"));
207    }
208    if node_type.contains("Subquery Scan")
209        || node_type.contains("SubPlan")
210        || node_type.contains("InitPlan")
211    {
212        *subquery_scans += 1;
213        bottlenecks.push(format!("{node_type} observed in plan"));
214    }
215
216    if plan_rows > 0.0 && actual_rows > 0.0 {
217        let variance = (actual_rows - plan_rows).abs() / plan_rows;
218        if variance > 0.5 {
219            bottlenecks.push(format!(
220                "Row estimation mismatch in {node_type}: planned {plan_rows}, actual {actual_rows}"
221            ));
222        }
223        let ratio = (actual_rows / plan_rows.max(1.0)).max(plan_rows / actual_rows.max(1.0));
224        if ratio > 10.0 {
225            *estimate_mismatches_over_10x += 1;
226        }
227    }
228
229    if node_type.contains("Bitmap Heap Scan")
230        && let Some(lossy) = node.get("Lossy Heap Blocks").and_then(|v| v.as_f64())
231        && lossy > 0.0
232    {
233        *lossy_bitmap_scans += 1;
234        bottlenecks.push(format!(
235            "Lossy bitmap heap scan detected ({lossy} lossy blocks)"
236        ));
237    }
238    let temp_written = node
239        .get("Temp Written Blocks")
240        .and_then(|v| v.as_f64())
241        .unwrap_or(0.0);
242    if temp_written > 0.0 {
243        *spilled_to_disk += 1;
244        bottlenecks.push(format!(
245            "{node_type} spilled to disk ({temp_written} temp blocks written)"
246        ));
247    }
248    if actual_time > 1000.0 {
249        bottlenecks.push(format!("{node_type} took {actual_time:.2}ms"));
250    }
251
252    if let Some(plans) = node.get("Plans").and_then(|v| v.as_array()) {
253        for child in plans {
254            analyze_plan_node(
255                child,
256                sequential_scans,
257                index_scans,
258                lossy_bitmap_scans,
259                spilled_to_disk,
260                estimate_mismatches_over_10x,
261                function_scans,
262                cte_scans,
263                subquery_scans,
264                bottlenecks,
265            );
266        }
267    }
268}
269
270/// Build EXPLAIN SQL for analyze tools (unit-tested).
271pub fn build_explain_sql(sql: &str, analyze: bool) -> String {
272    let options = if analyze {
273        "ANALYZE, BUFFERS, FORMAT JSON"
274    } else {
275        "FORMAT JSON"
276    };
277    format!("EXPLAIN ({options}) {sql}")
278}
279
280const CRITICAL_PERCENT: f64 = 40.0;
281const HIGH_PERCENT: f64 = 25.0;
282const MEDIUM_PERCENT: f64 = 15.0;
283const SKEW_SEVERE_RATIO: f64 = 10.0;
284const SKEW_HIGH_RATIO: f64 = 4.0;
285const SKEW_MEDIUM_RATIO: f64 = 2.0;
286const EXPENSIVE_NODE_TIME_MS: f64 = 1000.0;
287
288/// Severity-graded deep plan analysis (ported from pro `deepPlanAnalysis.ts`).
289pub fn analyze_deep_plan(explain_plan: &Value, query: &str) -> Option<Value> {
290    let plan_root = resolve_plan_root(explain_plan)?;
291    let plan_node = plan_root.get("Plan")?;
292
293    let total_cost = plan_node
294        .get("Total Cost")
295        .and_then(|v| v.as_f64())
296        .unwrap_or(0.0)
297        .max(1.0);
298    let total_execution_time = plan_node
299        .get("Actual Total Time")
300        .and_then(|v| v.as_f64())
301        .or_else(|| plan_root.get("Execution Time").and_then(|v| v.as_f64()))
302        .unwrap_or(0.0)
303        .max(1.0);
304
305    let mut functions = Vec::new();
306    let mut ctes: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
307    let mut subqueries = Vec::new();
308    let mut estimate_skew = Vec::new();
309
310    walk_deep_plan(
311        plan_node,
312        "root",
313        total_cost,
314        total_execution_time,
315        &mut functions,
316        &mut ctes,
317        &mut subqueries,
318        &mut estimate_skew,
319    );
320
321    let mut cte_list: Vec<Value> = ctes.into_values().collect();
322    cte_list.sort_by(|a, b| {
323        f64_desc(
324            a.get("cumulativeCost")
325                .and_then(|v| v.as_f64())
326                .unwrap_or(0.0),
327            b.get("cumulativeCost")
328                .and_then(|v| v.as_f64())
329                .unwrap_or(0.0),
330        )
331    });
332    functions.sort_by(|a, b| {
333        f64_desc(
334            a.get("cumulativeCost")
335                .and_then(|v| v.as_f64())
336                .unwrap_or(0.0),
337            b.get("cumulativeCost")
338                .and_then(|v| v.as_f64())
339                .unwrap_or(0.0),
340        )
341    });
342    subqueries.sort_by(|a, b| {
343        f64_desc(
344            a.get("cost").and_then(|v| v.as_f64()).unwrap_or(0.0),
345            b.get("cost").and_then(|v| v.as_f64()).unwrap_or(0.0),
346        )
347    });
348    estimate_skew.sort_by(|a, b| {
349        f64_desc(
350            a.get("skewRatio").and_then(|v| v.as_f64()).unwrap_or(0.0),
351            b.get("skewRatio").and_then(|v| v.as_f64()).unwrap_or(0.0),
352        )
353    });
354
355    let sql_shape = extract_sql_shape(query);
356    let recommendations =
357        build_deep_recommendations(&functions, &cte_list, &subqueries, &estimate_skew);
358
359    Some(json!({
360        "sqlShape": sql_shape,
361        "functions": functions,
362        "ctes": cte_list,
363        "subqueries": subqueries,
364        "estimateSkew": estimate_skew,
365        "recommendations": recommendations,
366    }))
367}
368
369fn f64_desc(a: f64, b: f64) -> std::cmp::Ordering {
370    b.partial_cmp(&a).unwrap_or(std::cmp::Ordering::Equal)
371}
372
373fn severity_from_percent(percent: f64) -> &'static str {
374    if percent >= CRITICAL_PERCENT {
375        "critical"
376    } else if percent >= HIGH_PERCENT {
377        "high"
378    } else if percent >= MEDIUM_PERCENT {
379        "medium"
380    } else {
381        "low"
382    }
383}
384
385fn severity_from_skew(skew_ratio: f64) -> &'static str {
386    if skew_ratio >= SKEW_SEVERE_RATIO {
387        "critical"
388    } else if skew_ratio >= SKEW_HIGH_RATIO {
389        "high"
390    } else if skew_ratio >= SKEW_MEDIUM_RATIO {
391        "medium"
392    } else {
393        "low"
394    }
395}
396
397fn to_percent(part: f64, total: f64) -> f64 {
398    if total <= 0.0 {
399        0.0
400    } else {
401        (part / total) * 100.0
402    }
403}
404
405fn is_ident_start(c: char) -> bool {
406    c.is_ascii_alphabetic() || c == '_'
407}
408
409fn is_ident_cont(c: char) -> bool {
410    c.is_ascii_alphanumeric() || c == '_' || c == '$'
411}
412
413/// Best-effort CTE / set-returning-function name scrape (no regex dep).
414fn extract_sql_shape(query: &str) -> Value {
415    let lower = query.to_ascii_lowercase();
416    let mut cte_names = Vec::new();
417    if let Some(with_pos) = lower.find("with")
418        && let Some(select_rel) = lower[with_pos..].find("select")
419    {
420        let body = &query[with_pos + 4..with_pos + select_rel];
421        let body_lower = body.to_ascii_lowercase();
422        let mut search_from = 0;
423        while let Some(as_rel) = body_lower[search_from..].find(" as ") {
424            let as_abs = search_from + as_rel;
425            let before = body[..as_abs].trim_end();
426            if let Some(name) = before
427                .rsplit(|c: char| !(is_ident_cont(c)))
428                .next()
429                .filter(|s| !s.is_empty() && is_ident_start(s.chars().next().unwrap()))
430            {
431                cte_names.push(name.to_string());
432            }
433            search_from = as_abs + 4;
434        }
435    }
436    cte_names.sort();
437    cte_names.dedup();
438
439    let mut from_function_names = Vec::new();
440    for keyword in ["from ", "join "] {
441        let mut search_from = 0;
442        while let Some(rel) = lower[search_from..].find(keyword) {
443            let start = search_from + rel + keyword.len();
444            let rest = &query[start..];
445            let rest_trim = rest.trim_start();
446            let skipped = rest.len() - rest_trim.len();
447            let mut end = 0;
448            let chars: Vec<char> = rest_trim.chars().collect();
449            if chars.first().copied().is_some_and(is_ident_start) {
450                end = 1;
451                while end < chars.len()
452                    && (is_ident_cont(chars[end])
453                        || (chars[end] == '.'
454                            && end + 1 < chars.len()
455                            && is_ident_start(chars[end + 1])))
456                {
457                    end += 1;
458                }
459                let after = chars.get(end..).map(|c| c.iter().collect::<String>());
460                if after
461                    .as_deref()
462                    .map(|s| s.trim_start().starts_with('('))
463                    .unwrap_or(false)
464                {
465                    let name: String = chars[..end].iter().collect();
466                    from_function_names.push(name);
467                }
468            }
469            search_from = start + skipped + end.max(1);
470        }
471    }
472    from_function_names.sort();
473    from_function_names.dedup();
474
475    json!({
476        "cteNames": cte_names,
477        "fromFunctionNames": from_function_names,
478    })
479}
480
481#[allow(clippy::too_many_arguments)]
482fn walk_deep_plan(
483    node: &Value,
484    path: &str,
485    total_cost: f64,
486    total_execution_time: f64,
487    functions: &mut Vec<Value>,
488    ctes: &mut std::collections::HashMap<String, Value>,
489    subqueries: &mut Vec<Value>,
490    estimate_skew: &mut Vec<Value>,
491) {
492    let node_type = node.get("Node Type").and_then(|v| v.as_str()).unwrap_or("");
493    let node_path = format!("{path}/{node_type}");
494    let total_node_cost = node
495        .get("Total Cost")
496        .and_then(|v| v.as_f64())
497        .unwrap_or(0.0);
498    let actual_total_time = node
499        .get("Actual Total Time")
500        .and_then(|v| v.as_f64())
501        .unwrap_or(0.0);
502    let plan_rows = node
503        .get("Plan Rows")
504        .and_then(|v| v.as_f64())
505        .unwrap_or(0.0);
506    let actual_rows = node
507        .get("Actual Rows")
508        .and_then(|v| v.as_f64())
509        .unwrap_or(0.0);
510    let actual_loops = node
511        .get("Actual Loops")
512        .and_then(|v| v.as_f64())
513        .unwrap_or(1.0);
514    let function_name = node
515        .get("Function Name")
516        .and_then(|v| v.as_str())
517        .map(str::to_owned);
518    let cte_name = node
519        .get("CTE Name")
520        .and_then(|v| v.as_str())
521        .map(str::to_owned);
522    let subplan_name = node
523        .get("Subplan Name")
524        .and_then(|v| v.as_str())
525        .map(str::to_owned);
526
527    let cost_percent = to_percent(total_node_cost, total_cost);
528    let time_percent = to_percent(actual_total_time, total_execution_time);
529    let dominant_percent = cost_percent.max(time_percent);
530
531    if node_type.contains("Function Scan") || function_name.is_some() {
532        let fname = function_name
533            .clone()
534            .unwrap_or_else(|| "unknown_function".into());
535        let severity = severity_from_percent(dominant_percent);
536        functions.push(json!({
537            "functionName": fname,
538            "nodeType": node_type,
539            "path": node_path,
540            "cumulativeTimeMs": actual_total_time,
541            "cumulativeCost": total_node_cost,
542            "loops": actual_loops,
543            "estimatedRows": plan_rows,
544            "actualRows": actual_rows,
545            "severity": severity,
546            "reason": format!(
547                "{fname} contributes {:.1}% of dominant plan weight",
548                dominant_percent
549            ),
550        }));
551    }
552
553    if node_type.contains("CTE Scan") || cte_name.is_some() {
554        let name = cte_name.unwrap_or_else(|| "unnamed_cte".into());
555        let existing = ctes.entry(name.clone()).or_insert_with(|| {
556            json!({
557                "cteName": name.clone(),
558                "scans": 0u64,
559                "cumulativeTimeMs": 0.0,
560                "cumulativeCost": 0.0,
561                "rowsRead": 0.0,
562                "severity": "low",
563                "reason": "",
564            })
565        });
566        let scans = existing.get("scans").and_then(|v| v.as_u64()).unwrap_or(0) + 1;
567        let cum_time = existing
568            .get("cumulativeTimeMs")
569            .and_then(|v| v.as_f64())
570            .unwrap_or(0.0)
571            + actual_total_time;
572        let cum_cost = existing
573            .get("cumulativeCost")
574            .and_then(|v| v.as_f64())
575            .unwrap_or(0.0)
576            + total_node_cost;
577        let rows_read = existing
578            .get("rowsRead")
579            .and_then(|v| v.as_f64())
580            .unwrap_or(0.0)
581            + actual_rows;
582        let cte_percent =
583            to_percent(cum_cost, total_cost).max(to_percent(cum_time, total_execution_time));
584        let severity = severity_from_percent(cte_percent);
585        *existing = json!({
586            "cteName": name,
587            "scans": scans,
588            "cumulativeTimeMs": cum_time,
589            "cumulativeCost": cum_cost,
590            "rowsRead": rows_read,
591            "severity": severity,
592            "reason": format!(
593                "{name} scanned {scans} time(s), {cte_percent:.1}% dominant contribution"
594            ),
595        });
596    }
597
598    if node_type.contains("Subquery Scan")
599        || node_type.contains("InitPlan")
600        || node_type.contains("SubPlan")
601        || subplan_name.is_some()
602    {
603        let severity = severity_from_percent(dominant_percent);
604        subqueries.push(json!({
605            "nodeType": node_type,
606            "path": node_path,
607            "subplanName": subplan_name,
608            "timeMs": actual_total_time,
609            "cost": total_node_cost,
610            "severity": severity,
611            "reason": format!(
612                "{node_type} contributes {:.1}% of dominant plan weight",
613                dominant_percent
614            ),
615        }));
616    }
617
618    if plan_rows > 0.0 && actual_rows > 0.0 {
619        let skew_ratio = (actual_rows / plan_rows).max(plan_rows / actual_rows);
620        if skew_ratio >= SKEW_MEDIUM_RATIO {
621            let severity = severity_from_skew(skew_ratio);
622            estimate_skew.push(json!({
623                "nodeType": node_type,
624                "path": node_path,
625                "planRows": plan_rows,
626                "actualRows": actual_rows,
627                "skewRatio": skew_ratio,
628                "severity": severity,
629                "reason": format!(
630                    "Planner skew {skew_ratio:.1}x between estimated and actual rows"
631                ),
632            }));
633        }
634    }
635
636    if let Some(plans) = node.get("Plans").and_then(|v| v.as_array()) {
637        for child in plans {
638            walk_deep_plan(
639                child,
640                &node_path,
641                total_cost,
642                total_execution_time,
643                functions,
644                ctes,
645                subqueries,
646                estimate_skew,
647            );
648        }
649    }
650}
651
652fn build_deep_recommendations(
653    functions: &[Value],
654    ctes: &[Value],
655    subqueries: &[Value],
656    estimate_skew: &[Value],
657) -> Vec<String> {
658    let mut recommendations = Vec::new();
659    if let Some(f) = functions.iter().find(|f| {
660        matches!(
661            f.get("severity").and_then(|v| v.as_str()),
662            Some("critical" | "high")
663        )
664    }) {
665        let name = f
666            .get("functionName")
667            .and_then(|v| v.as_str())
668            .unwrap_or("unknown");
669        recommendations.push(format!(
670            "Function scan hotspot on {name}. Inspect function logic and ensure predicates push down before invocation."
671        ));
672    }
673    if let Some(c) = ctes.iter().find(|c| {
674        c.get("scans").and_then(|v| v.as_u64()).unwrap_or(0) > 1
675            || c.get("severity").and_then(|v| v.as_str()) == Some("critical")
676    }) {
677        let name = c.get("cteName").and_then(|v| v.as_str()).unwrap_or("cte");
678        let scans = c.get("scans").and_then(|v| v.as_u64()).unwrap_or(0);
679        recommendations.push(format!(
680            "CTE {name} is reused {scans} times. Consider inline rewrite or reducing CTE output width/rows."
681        ));
682    }
683    if let Some(s) = estimate_skew
684        .iter()
685        .find(|s| s.get("severity").and_then(|v| v.as_str()) == Some("critical"))
686    {
687        let skew = s.get("skewRatio").and_then(|v| v.as_f64()).unwrap_or(0.0);
688        let node_type = s.get("nodeType").and_then(|v| v.as_str()).unwrap_or("node");
689        recommendations.push(format!(
690            "Severe estimate skew ({skew:.1}x) in {node_type}. Run ANALYZE and review predicate selectivity/index coverage."
691        ));
692    }
693    if let Some(s) = subqueries
694        .iter()
695        .find(|s| s.get("timeMs").and_then(|v| v.as_f64()).unwrap_or(0.0) >= EXPENSIVE_NODE_TIME_MS)
696    {
697        let node_type = s.get("nodeType").and_then(|v| v.as_str()).unwrap_or("node");
698        let time = s.get("timeMs").and_then(|v| v.as_f64()).unwrap_or(0.0);
699        recommendations.push(format!(
700            "Expensive {node_type} detected ({time:.1}ms). Evaluate join rewrite or pre-aggregation."
701        ));
702    }
703    if recommendations.is_empty() {
704        recommendations
705            .push("No deep function/CTE/subquery anti-patterns detected in current plan.".into());
706    }
707    recommendations
708}
709
710#[cfg(test)]
711mod tests {
712    use super::*;
713
714    #[test]
715    fn build_explain_analyze_wraps() {
716        assert_eq!(
717            build_explain_sql("SELECT 1", true),
718            "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT 1"
719        );
720        assert_eq!(
721            build_explain_sql("SELECT 1", false),
722            "EXPLAIN (FORMAT JSON) SELECT 1"
723        );
724    }
725
726    #[test]
727    fn extract_metrics_from_seq_scan_plan() {
728        let plan = json!({
729            "Plan": {
730                "Node Type": "Seq Scan",
731                "Relation Name": "users",
732                "Total Cost": 25.0,
733                "Plan Rows": 100,
734                "Actual Rows": 100,
735                "Actual Total Time": 1.5
736            },
737            "Planning Time": 0.1,
738            "Execution Time": 1.6
739        });
740        let metrics = extract_plan_metrics(&plan).expect("metrics");
741        assert_eq!(metrics["sequentialScans"], 1);
742        assert_eq!(metrics["indexScans"], 0);
743        let recs = metrics["recommendations"].as_array().unwrap();
744        assert!(recs.iter().any(|r| r.as_str().unwrap().contains("indexes")));
745    }
746
747    #[test]
748    fn deep_plan_flags_estimate_skew() {
749        let plan = json!({
750            "Plan": {
751                "Node Type": "Seq Scan",
752                "Relation Name": "users",
753                "Total Cost": 100.0,
754                "Plan Rows": 10,
755                "Actual Rows": 1000,
756                "Actual Total Time": 50.0,
757                "Actual Loops": 1
758            },
759            "Execution Time": 50.0
760        });
761        let deep = analyze_deep_plan(&plan, "SELECT * FROM users").expect("deep");
762        let skew = deep["estimateSkew"].as_array().expect("skew arr");
763        assert!(!skew.is_empty());
764        assert_eq!(skew[0]["severity"], "critical");
765        assert!(
766            deep["recommendations"]
767                .as_array()
768                .unwrap()
769                .iter()
770                .any(|r| r.as_str().unwrap().contains("Severe estimate skew"))
771        );
772    }
773}