pub struct ExplainWarning {
pub message: String,
}
pub fn explain_threshold() -> u64 {
std::env::var("BSQL_EXPLAIN_THRESHOLD")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1000)
}
pub fn analyze_plan(plan_text: &str, threshold: u64) -> Vec<ExplainWarning> {
let mut warnings = Vec::new();
for line in plan_text.lines() {
let trimmed = line.trim().trim_start_matches("-> ");
if let Some(warning) = check_seq_scan(trimmed, threshold) {
warnings.push(warning);
}
if let Some(warning) = check_sort_without_index(trimmed, plan_text) {
warnings.push(warning);
}
}
warnings
}
fn check_seq_scan(line: &str, threshold: u64) -> Option<ExplainWarning> {
let table = parse_table_name(line)?;
let rows = parse_rows_estimate(line)?;
if rows > threshold {
Some(ExplainWarning {
message: format!(
"Seq Scan on `{}` with estimated {} rows (threshold: {}). \
Consider adding an index.",
table, rows, threshold
),
})
} else {
None
}
}
fn check_sort_without_index(line: &str, full_plan: &str) -> Option<ExplainWarning> {
if !line.starts_with("Sort") {
return None;
}
let after_sort = &line[4..];
if !after_sort.starts_with(' ') && !after_sort.starts_with('\t') {
return None;
}
if !after_sort.contains("(cost=") {
return None;
}
let has_index_scan = full_plan.contains("Index Scan") || full_plan.contains("Index Only Scan");
if !has_index_scan {
Some(ExplainWarning {
message: "Sort without index backing detected. \
Consider adding an index on the sort columns."
.to_owned(),
})
} else {
None
}
}
fn parse_rows_estimate(line: &str) -> Option<u64> {
let rows_start = line.find("rows=")?;
let after_rows = &line[rows_start + 5..];
let end = after_rows
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(after_rows.len());
if end == 0 {
return None;
}
after_rows[..end].parse().ok()
}
fn parse_table_name(line: &str) -> Option<&str> {
let prefix = "Seq Scan on ";
let start = line.find(prefix)?;
let after = &line[start + prefix.len()..];
let end = after
.find(|c: char| c == ' ' || c == '(')
.unwrap_or(after.len());
if end == 0 {
return None;
}
Some(&after[..end])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn seq_scan_above_threshold_warns() {
let plan = "Seq Scan on users (cost=0.00..35.50 rows=5000 width=36)";
let warnings = analyze_plan(plan, 1000);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("Seq Scan"));
assert!(warnings[0].message.contains("users"));
}
#[test]
fn seq_scan_below_threshold_no_warning() {
let plan = "Seq Scan on users (cost=0.00..1.10 rows=10 width=36)";
let warnings = analyze_plan(plan, 1000);
assert!(warnings.is_empty());
}
#[test]
fn seq_scan_at_threshold_no_warning() {
let plan = "Seq Scan on users (cost=0.00..35.50 rows=1000 width=36)";
let warnings = analyze_plan(plan, 1000);
assert!(warnings.is_empty()); }
#[test]
fn index_scan_no_warning() {
let plan = "Index Scan using users_pkey on users (cost=0.00..8.27 rows=1 width=36)";
let warnings = analyze_plan(plan, 1000);
assert!(warnings.is_empty());
}
#[test]
fn index_only_scan_no_warning() {
let plan =
"Index Only Scan using idx_users_email on users (cost=0.00..1.05 rows=1 width=36)";
let warnings = analyze_plan(plan, 1000);
assert!(warnings.is_empty());
}
#[test]
fn nested_plan_seq_scan_detected() {
let plan = "\
Nested Loop (cost=0.00..500.00 rows=1000 width=72)
-> Index Scan using orders_pkey on orders (cost=0.00..8.27 rows=1 width=36)
-> Seq Scan on order_items (cost=0.00..50.00 rows=5000 width=36)
Filter: (order_id = orders.id)";
let warnings = analyze_plan(plan, 1000);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("order_items"));
}
#[test]
fn multiple_seq_scans_multiple_warnings() {
let plan = "\
Hash Join (cost=100.00..500.00 rows=1000 width=72)
-> Seq Scan on users (cost=0.00..35.50 rows=2550 width=36)
-> Seq Scan on orders (cost=0.00..50.00 rows=3000 width=36)";
let warnings = analyze_plan(plan, 1000);
assert_eq!(warnings.len(), 2);
}
#[test]
fn threshold_zero_warns_on_any_seq_scan() {
let plan = "Seq Scan on tiny_table (cost=0.00..1.01 rows=1 width=4)";
let warnings = analyze_plan(plan, 0);
assert_eq!(warnings.len(), 1);
}
#[test]
fn threshold_max_never_warns() {
let plan = "Seq Scan on huge (cost=0.00..99999.00 rows=9999999 width=36)";
let warnings = analyze_plan(plan, u64::MAX);
assert!(warnings.is_empty());
}
#[test]
fn empty_plan_no_warnings() {
let warnings = analyze_plan("", 1000);
assert!(warnings.is_empty());
}
#[test]
fn malformed_plan_no_crash() {
let warnings = analyze_plan("this is not a plan at all {{{", 1000);
assert!(warnings.is_empty());
}
#[test]
fn plan_with_cte() {
let plan = "\
CTE Scan on cte (cost=0.00..20.00 rows=1000 width=36)
CTE cte
-> Seq Scan on large_table (cost=0.00..500.00 rows=50000 width=36)";
let warnings = analyze_plan(plan, 1000);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("large_table"));
}
#[test]
fn plan_with_subquery() {
let plan = "\
Seq Scan on products (cost=0.00..100.00 rows=5000 width=36)
Filter: (price > (SubPlan 1))
SubPlan 1
-> Aggregate (cost=10.00..10.01 rows=1 width=8)
-> Seq Scan on prices (cost=0.00..8.00 rows=800 width=8)";
let warnings = analyze_plan(plan, 500);
assert_eq!(warnings.len(), 2); }
#[test]
fn rows_estimate_parsing() {
assert_eq!(
parse_rows_estimate("(cost=0.00..1.00 rows=42 width=4)"),
Some(42)
);
assert_eq!(
parse_rows_estimate("(cost=0.00..1.00 rows=0 width=4)"),
Some(0)
);
assert_eq!(parse_rows_estimate("no rows here"), None);
assert_eq!(parse_rows_estimate("rows="), None); }
#[test]
fn table_name_parsing() {
assert_eq!(parse_table_name("Seq Scan on users (cost="), Some("users"));
assert_eq!(
parse_table_name("Seq Scan on my_schema.users (cost="),
Some("my_schema.users")
);
assert_eq!(parse_table_name("Index Scan on users"), None); }
#[test]
fn schema_qualified_table_name() {
let plan = "Seq Scan on public.users (cost=0.00..35.50 rows=5000 width=36)";
let warnings = analyze_plan(plan, 1000);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("public.users"));
}
#[test]
fn aliased_table() {
let plan = "Seq Scan on users u (cost=0.00..35.50 rows=5000 width=36)";
let warnings = analyze_plan(plan, 1000);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("users"));
}
#[test]
fn explain_threshold_default_when_unset() {
if std::env::var("BSQL_EXPLAIN_THRESHOLD").is_err() {
assert_eq!(explain_threshold(), 1000);
}
}
#[test]
fn threshold_boundaries_exercised() {
let plan = "Seq Scan on t (cost=0.00..10.00 rows=500 width=4)";
assert!(analyze_plan(plan, 499).len() == 1); assert!(analyze_plan(plan, 500).is_empty()); assert!(analyze_plan(plan, 501).is_empty()); }
#[test]
fn sort_in_plan_warns() {
let plan = "\
Sort (cost=100.00..110.00 rows=5000 width=36)
Sort Key: created_at
-> Seq Scan on events (cost=0.00..80.00 rows=5000 width=36)";
let warnings = analyze_plan(plan, 1000);
assert!(warnings.len() >= 2);
let has_sort_warning = warnings
.iter()
.any(|w| w.message.contains("Sort without index"));
assert!(has_sort_warning, "should detect sort without index");
}
#[test]
fn sort_with_index_scan_no_sort_warning() {
let plan = "\
Sort (cost=10.00..11.00 rows=100 width=36)
Sort Key: name
-> Index Scan using idx_users_name on users (cost=0.00..8.27 rows=100 width=36)";
let warnings = analyze_plan(plan, 1000);
assert!(warnings.is_empty());
}
#[test]
fn sort_key_line_not_mistaken_for_sort_node() {
let plan = "\
Index Scan using idx on users (cost=0.00..8.27 rows=1 width=36)
Sort Key: name";
let warnings = analyze_plan(plan, 1000);
assert!(warnings.is_empty());
}
#[test]
fn sort_method_line_not_mistaken_for_sort_node() {
let plan = "\
Sort (cost=100.00..110.00 rows=100 width=36)
Sort Key: name
Sort Method: quicksort Memory: 25kB
-> Index Scan using idx on users (cost=0.00..50.00 rows=100 width=36)";
let warnings = analyze_plan(plan, 1000);
assert!(warnings.is_empty());
}
#[test]
fn plan_only_whitespace() {
let warnings = analyze_plan(" \n \n ", 1000);
assert!(warnings.is_empty());
}
#[test]
fn plan_with_arrow_prefix() {
let plan = " -> Seq Scan on big_table (cost=0.00..999.00 rows=50000 width=36)";
let warnings = analyze_plan(plan, 1000);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("big_table"));
}
#[test]
fn rows_zero_below_any_positive_threshold() {
let plan = "Seq Scan on empty_table (cost=0.00..0.00 rows=0 width=0)";
let warnings = analyze_plan(plan, 1);
assert!(warnings.is_empty()); }
#[test]
fn rows_one_at_threshold_zero() {
let plan = "Seq Scan on t (cost=0.00..1.00 rows=1 width=4)";
let warnings = analyze_plan(plan, 0);
assert_eq!(warnings.len(), 1); }
mod proptest_fuzz {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn analyze_plan_never_panics(plan in ".*") {
let _ = analyze_plan(&plan, 1000);
}
#[test]
fn analyze_plan_never_panics_with_seq_scan_like(
table in "[a-z_]{1,30}",
rows in 0u64..1_000_000,
cost in 0.0f64..100000.0,
) {
let plan = format!("Seq Scan on {table} (cost=0.00..{cost:.2} rows={rows} width=36)");
let warnings = analyze_plan(&plan, 1000);
if rows > 1000 {
assert_eq!(warnings.len(), 1);
}
}
#[test]
fn parse_rows_estimate_never_panics(s in ".*") {
let _ = parse_rows_estimate(&s);
}
#[test]
fn parse_table_name_never_panics(s in ".*") {
let _ = parse_table_name(&s);
}
#[test]
fn threshold_zero_always_warns_on_seq_scan(
table in "[a-z]{1,20}",
rows in 1u64..1_000_000,
) {
let plan = format!("Seq Scan on {table} (cost=0.00..10.00 rows={rows} width=4)");
let warnings = analyze_plan(&plan, 0);
assert!(!warnings.is_empty(), "threshold 0 should warn on rows={rows}");
}
#[test]
fn analyze_plan_idempotent(plan in ".{0,200}") {
let w1 = analyze_plan(&plan, 500);
let w2 = analyze_plan(&plan, 500);
assert_eq!(w1.len(), w2.len());
}
}
}
}