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 && 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 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
270pub 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
288pub 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
413fn 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}