use std::collections::HashMap;
#[derive(Debug, Clone, Default)]
pub struct GraphStats {
pub total_nodes: usize,
pub total_edges: usize,
pub nodes_per_label: HashMap<String, usize>,
pub edges_per_type: HashMap<String, usize>,
pub avg_degree: f64,
pub property_cardinality: HashMap<String, usize>,
}
impl GraphStats {
pub fn new() -> Self {
Self::default()
}
}
#[derive(Debug, Clone)]
pub enum PlanNode {
LabelScan {
label: String,
estimated_rows: usize,
cost: f64,
},
IndexSeek {
index_name: String,
label: String,
property: String,
estimated_rows: usize,
cost: f64,
},
Filter {
predicate: String,
input: Box<PlanNode>,
selectivity: f64,
estimated_rows: usize,
cost: f64,
},
Expand {
edge_type: String,
direction: String,
input: Box<PlanNode>,
estimated_rows: usize,
cost: f64,
},
}
impl PlanNode {
pub fn estimated_rows(&self) -> usize {
match self {
PlanNode::LabelScan { estimated_rows, .. } => *estimated_rows,
PlanNode::IndexSeek { estimated_rows, .. } => *estimated_rows,
PlanNode::Filter { estimated_rows, .. } => *estimated_rows,
PlanNode::Expand { estimated_rows, .. } => *estimated_rows,
}
}
pub fn cost(&self) -> f64 {
match self {
PlanNode::LabelScan { cost, .. } => *cost,
PlanNode::IndexSeek { cost, .. } => *cost,
PlanNode::Filter { cost, .. } => *cost,
PlanNode::Expand { cost, .. } => *cost,
}
}
}
const SCAN_COST_PER_NODE: f64 = 1.0;
const INDEX_SEEK_BASE_COST: f64 = 10.0;
const INDEX_SEEK_PER_ROW: f64 = 0.1;
const FILTER_COST_PER_ROW: f64 = 0.1;
const _EXPAND_COST_PER_EDGE: f64 = 0.5;
pub struct QueryPlanner {
stats: GraphStats,
}
impl QueryPlanner {
pub fn new(stats: GraphStats) -> Self {
Self { stats }
}
pub fn estimate_label_scan(&self, label: &str) -> (usize, f64) {
let estimated_rows = self.stats.nodes_per_label
.get(label)
.copied()
.unwrap_or(self.stats.total_nodes);
let cost = estimated_rows as f64 * SCAN_COST_PER_NODE;
(estimated_rows, cost)
}
pub fn estimate_index_seek(&self, label: &str, property: &str) -> (usize, f64) {
let key = format!("{}_{}", label, property);
let cardinality = self.stats.property_cardinality
.get(&key)
.copied()
.unwrap_or(1);
let total_nodes = self.stats.nodes_per_label
.get(label)
.copied()
.unwrap_or(self.stats.total_nodes);
let estimated_rows = total_nodes.checked_div(cardinality).unwrap_or(1);
let cost = INDEX_SEEK_BASE_COST + (estimated_rows as f64 * INDEX_SEEK_PER_ROW);
(estimated_rows, cost)
}
pub fn choose_best_plan(
&self,
label: &str,
property: Option<&str>,
has_index: bool,
) -> PlanNode {
if let Some(prop) = property {
if has_index {
let (index_rows, index_cost) = self.estimate_index_seek(label, prop);
let (_scan_rows, scan_cost) = self.estimate_label_scan(label);
if index_cost < scan_cost {
log::info!(
"🚀 Planner: Using INDEX SEEK (cost: {:.2} vs {:.2})",
index_cost, scan_cost
);
return PlanNode::IndexSeek {
index_name: format!("{}_{}", label, prop),
label: label.to_string(),
property: prop.to_string(),
estimated_rows: index_rows,
cost: index_cost,
};
}
log::info!(
"⚠️ Planner: INDEX exists but SCAN is faster (index: {:.2}, scan: {:.2})",
index_cost, scan_cost
);
} else {
log::warn!("⚠️ Planner: No index on {}.{}, using SCAN", label, prop);
}
}
let (rows, cost) = self.estimate_label_scan(label);
log::info!("📊 Planner: Using LABEL SCAN (cost: {:.2})", cost);
PlanNode::LabelScan {
label: label.to_string(),
estimated_rows: rows,
cost,
}
}
pub fn add_filter(
&self,
input: PlanNode,
selectivity: f64,
predicate: String,
) -> PlanNode {
let input_rows = input.estimated_rows();
let estimated_rows = (input_rows as f64 * selectivity).max(1.0) as usize;
let filter_cost = input_rows as f64 * FILTER_COST_PER_ROW;
let total_cost = input.cost() + filter_cost;
PlanNode::Filter {
predicate,
input: Box::new(input),
selectivity,
estimated_rows,
cost: total_cost,
}
}
pub fn format_plan(&self, plan: &PlanNode, indent: usize) -> String {
let prefix = "│ ".repeat(indent);
match plan {
PlanNode::LabelScan { label, estimated_rows, cost } => {
format!(
"{}→ LABEL SCAN: {} | rows=~{} | cost={:.2}",
prefix, label, estimated_rows, cost
)
}
PlanNode::IndexSeek { index_name, estimated_rows, cost, .. } => {
format!(
"{}→ INDEX SEEK: {} | rows=~{} | cost={:.2}",
prefix, index_name, estimated_rows, cost
)
}
PlanNode::Filter { predicate, input, estimated_rows, cost, selectivity } => {
let mut s = format!(
"{}→ FILTER: {} | selectivity={:.2} | rows=~{} | cost={:.2}\n",
prefix, predicate, selectivity, estimated_rows, cost
);
s.push_str(&self.format_plan(input, indent + 1));
s
}
PlanNode::Expand { edge_type, direction, input, estimated_rows, cost } => {
let mut s = format!(
"{}→ EXPAND: {} ({}) | rows=~{} | cost={:.2}\n",
prefix, edge_type, direction, estimated_rows, cost
);
s.push_str(&self.format_plan(input, indent + 1));
s
}
}
}
pub fn explain(&self, plan: &PlanNode) -> String {
let plan_str = self.format_plan(plan, 0);
let total_cost = plan.cost();
let total_rows = plan.estimated_rows();
format!(
"┌─────────────────────────────────────────┐\n\
│ Query Execution Plan │\n\
├─────────────────────────────────────────┤\n\
{}\n\
├─────────────────────────────────────────┤\n\
│ Total Cost: {:.2} │\n\
│ Estimated Rows: ~{} │\n\
│ Estimated Time: {:.2}ms │\n\
└─────────────────────────────────────────┘",
plan_str, total_cost, total_rows, total_cost / 1000.0
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_planner_chooses_index_when_better() {
let mut stats = GraphStats::new();
stats.total_nodes = 1_000_000;
stats.nodes_per_label.insert("Person".to_string(), 1_000_000);
stats.property_cardinality.insert("Person_email".to_string(), 500_000);
let planner = QueryPlanner::new(stats);
let plan = planner.choose_best_plan("Person", Some("email"), true);
assert!(matches!(plan, PlanNode::IndexSeek { .. }));
assert!(plan.cost() < 100.0); }
#[test]
fn test_planner_chooses_scan_without_index() {
let mut stats = GraphStats::new();
stats.total_nodes = 1000;
stats.nodes_per_label.insert("Person".to_string(), 1000);
let planner = QueryPlanner::new(stats);
let plan = planner.choose_best_plan("Person", Some("name"), false);
assert!(matches!(plan, PlanNode::LabelScan { .. }));
}
#[test]
fn test_filter_reduces_rows() {
let stats = GraphStats::new();
let planner = QueryPlanner::new(stats);
let scan = PlanNode::LabelScan {
label: "Person".to_string(),
estimated_rows: 1000,
cost: 1000.0,
};
let filtered = planner.add_filter(scan, 0.1, "age > 30".to_string());
assert_eq!(filtered.estimated_rows(), 100); assert!(filtered.cost() > 1000.0); }
}