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