1use serde_json::{Value, json};
7
8pub 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 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
272pub 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
290pub 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
415fn 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}