use crate::regression::{PlanBaseline, PlanRegression};
use crate::{ExplainPlan, ScanType};
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use std::time::Duration;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
pub enum IssueSeverity {
Info,
Warning,
Critical,
}
impl IssueSeverity {
pub fn as_str(&self) -> &'static str {
match self {
Self::Info => "info",
Self::Warning => "warning",
Self::Critical => "critical",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PlanIssue {
pub kind: IssueKind,
pub severity: IssueSeverity,
pub description: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum IssueKind {
FullTableScan,
LargeResult,
MissingIndex,
Filesort,
TemporaryTable,
HighCost,
}
impl IssueKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::FullTableScan => "full-table-scan",
Self::LargeResult => "large-result",
Self::MissingIndex => "missing-index",
Self::Filesort => "filesort",
Self::TemporaryTable => "temporary-table",
Self::HighCost => "high-cost",
}
}
}
#[derive(Debug, Clone)]
pub struct QueryPlanAnalyzer {
pub large_result_threshold: u64,
pub missing_index_threshold: u64,
pub high_cost_threshold: f64,
}
impl Default for QueryPlanAnalyzer {
fn default() -> Self {
Self {
large_result_threshold: 1000,
missing_index_threshold: 100,
high_cost_threshold: 1000.0,
}
}
}
impl QueryPlanAnalyzer {
pub fn new() -> Self {
Self::default()
}
pub fn with_large_result_threshold(mut self, n: u64) -> Self {
self.large_result_threshold = n;
self
}
pub fn with_missing_index_threshold(mut self, n: u64) -> Self {
self.missing_index_threshold = n;
self
}
pub fn with_high_cost_threshold(mut self, c: f64) -> Self {
self.high_cost_threshold = c;
self
}
pub fn analyze(&self, plan: &ExplainPlan) -> Vec<PlanIssue> {
let mut issues = Vec::new();
if plan.is_full_table_scan() {
issues.push(PlanIssue {
kind: IssueKind::FullTableScan,
severity: IssueSeverity::Critical,
description: format!(
"table '{}' uses full table scan (rows={})",
plan.table, plan.rows
),
});
}
if plan.is_large_result(self.large_result_threshold) {
issues.push(PlanIssue {
kind: IssueKind::LargeResult,
severity: IssueSeverity::Warning,
description: format!(
"table '{}' returns {} rows (threshold={})",
plan.table, plan.rows, self.large_result_threshold
),
});
}
if plan.missing_index(self.missing_index_threshold) {
issues.push(PlanIssue {
kind: IssueKind::MissingIndex,
severity: IssueSeverity::Critical,
description: format!(
"table '{}' missing index (scan={:?}, rows={})",
plan.table, plan.scan_type, plan.rows
),
});
}
if plan.has_extra("filesort") || plan.has_extra("Using filesort") {
issues.push(PlanIssue {
kind: IssueKind::Filesort,
severity: IssueSeverity::Warning,
description: format!("table '{}' requires filesort", plan.table),
});
}
if plan.has_extra("temporary") || plan.has_extra("Using temporary") {
issues.push(PlanIssue {
kind: IssueKind::TemporaryTable,
severity: IssueSeverity::Warning,
description: format!("table '{}' uses temporary table", plan.table),
});
}
let cost = estimate_cost(plan);
if cost > self.high_cost_threshold {
issues.push(PlanIssue {
kind: IssueKind::HighCost,
severity: IssueSeverity::Critical,
description: format!(
"table '{}' estimated cost {:.2} exceeds threshold {:.2}",
plan.table, cost, self.high_cost_threshold
),
});
}
issues.sort_by_key(|i| std::cmp::Reverse(i.severity));
issues
}
pub fn analyze_many(&self, plans: &[ExplainPlan]) -> Vec<(usize, Vec<PlanIssue>)> {
plans
.iter()
.enumerate()
.map(|(i, p)| (i, self.analyze(p)))
.collect()
}
pub fn has_critical(&self, plan: &ExplainPlan) -> bool {
self.analyze(plan)
.iter()
.any(|i| i.severity == IssueSeverity::Critical)
}
}
fn estimate_cost(plan: &ExplainPlan) -> f64 {
let rows = plan.rows as f64;
match plan.scan_type {
ScanType::FullTable => rows,
ScanType::IndexRange => rows * 0.5,
ScanType::IndexLookup => (rows + 1.0).log2() * 10.0,
ScanType::UniqueLookup => (rows + 1.0).log2() * 5.0,
ScanType::Other => rows * 0.8,
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct QueryPattern {
pub table: String,
pub where_columns: Vec<String>,
pub order_by_columns: Vec<String>,
pub join_columns: Vec<String>,
}
impl QueryPattern {
pub fn new(table: impl Into<String>) -> Self {
Self {
table: table.into(),
where_columns: Vec::new(),
order_by_columns: Vec::new(),
join_columns: Vec::new(),
}
}
pub fn with_where(mut self, col: impl Into<String>) -> Self {
self.where_columns.push(col.into());
self
}
pub fn with_order_by(mut self, col: impl Into<String>) -> Self {
self.order_by_columns.push(col.into());
self
}
pub fn with_join(mut self, col: impl Into<String>) -> Self {
self.join_columns.push(col.into());
self
}
pub fn has_where(&self) -> bool {
!self.where_columns.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct IndexAdvice {
pub table: String,
pub columns: Vec<String>,
pub reason: String,
pub benefit_level: u8,
}
impl IndexAdvice {
pub fn suggested_name(&self) -> String {
format!("idx_{}", self.columns.join("_"))
}
}
#[derive(Debug, Clone, Default)]
pub struct IndexAdvisor {
pub existing_indexes: HashMap<String, Vec<Vec<String>>>,
}
impl IndexAdvisor {
pub fn new() -> Self {
Self::default()
}
pub fn with_existing_index(mut self, table: &str, columns: Vec<String>) -> Self {
self.existing_indexes
.entry(table.to_string())
.or_default()
.push(columns);
self
}
pub fn advise(&self, pattern: &QueryPattern) -> Option<IndexAdvice> {
if pattern.where_columns.is_empty() && pattern.join_columns.is_empty() {
return None;
}
let mut columns = Vec::new();
for col in &pattern.where_columns {
if !columns.contains(col) {
columns.push(col.clone());
}
}
for col in &pattern.order_by_columns {
if !columns.contains(col) {
columns.push(col.clone());
}
}
for col in &pattern.join_columns {
if !columns.contains(col) {
columns.push(col.clone());
}
}
if columns.is_empty() {
return None;
}
if self.has_covering_index(&pattern.table, &columns) {
return None;
}
let mut benefit = 1u8;
if !pattern.join_columns.is_empty() {
benefit += 1;
}
if pattern.where_columns.len() > 1 {
benefit += 1;
}
if !pattern.order_by_columns.is_empty() {
benefit += 1;
}
let benefit = benefit.min(3);
let reason = if !pattern.join_columns.is_empty() {
format!("join column(s) on '{}' benefit from index", pattern.table)
} else if pattern.where_columns.len() > 1 {
format!(
"composite index on '{}' accelerates multi-column filter",
pattern.table
)
} else {
format!(
"single-column index on '{}' accelerates lookup",
pattern.table
)
};
Some(IndexAdvice {
table: pattern.table.clone(),
columns,
reason,
benefit_level: benefit,
})
}
pub fn advise_many(&self, patterns: &[QueryPattern]) -> Vec<IndexAdvice> {
let mut seen: Vec<(String, Vec<String>)> = Vec::new();
let mut advices = Vec::new();
for p in patterns {
if let Some(advice) = self.advise(p) {
let key = (advice.table.clone(), advice.columns.clone());
if !seen.contains(&key) {
seen.push(key);
advices.push(advice);
}
}
}
advices
}
fn has_covering_index(&self, table: &str, columns: &[String]) -> bool {
let existing = match self.existing_indexes.get(table) {
Some(v) => v,
None => return false,
};
for idx_cols in existing {
if idx_cols.len() >= columns.len() {
let prefix = &idx_cols[..columns.len()];
if prefix == columns {
return true;
}
}
}
false
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ExecutionStats {
pub query_key: String,
pub total_executions: u64,
pub success_count: u64,
pub failure_count: u64,
pub total_elapsed_ms: u64,
pub min_elapsed_ms: u64,
pub max_elapsed_ms: u64,
}
impl ExecutionStats {
pub fn new(query_key: impl Into<String>) -> Self {
Self {
query_key: query_key.into(),
total_executions: 0,
success_count: 0,
failure_count: 0,
total_elapsed_ms: 0,
min_elapsed_ms: 0,
max_elapsed_ms: 0,
}
}
pub fn avg_elapsed_ms(&self) -> f64 {
if self.total_executions == 0 {
0.0
} else {
self.total_elapsed_ms as f64 / self.total_executions as f64
}
}
pub fn error_rate(&self) -> f64 {
if self.total_executions == 0 {
0.0
} else {
self.failure_count as f64 / self.total_executions as f64
}
}
pub fn record(&mut self, elapsed: Duration, success: bool) {
let ms = elapsed.as_millis() as u64;
self.total_executions += 1;
if success {
self.success_count += 1;
} else {
self.failure_count += 1;
}
self.total_elapsed_ms = self.total_elapsed_ms.saturating_add(ms);
if self.min_elapsed_ms == 0 || ms < self.min_elapsed_ms {
self.min_elapsed_ms = ms;
}
if ms > self.max_elapsed_ms {
self.max_elapsed_ms = ms;
}
}
}
pub struct ExecutionStatsCollector {
stats: Mutex<HashMap<String, ExecutionStats>>,
global_total: AtomicU64,
global_failures: AtomicU64,
}
impl Default for ExecutionStatsCollector {
fn default() -> Self {
Self::new()
}
}
impl ExecutionStatsCollector {
pub fn new() -> Self {
Self {
stats: Mutex::new(HashMap::new()),
global_total: AtomicU64::new(0),
global_failures: AtomicU64::new(0),
}
}
pub fn record(&self, query_key: &str, elapsed: Duration, success: bool) {
self.global_total.fetch_add(1, Ordering::Relaxed);
if !success {
self.global_failures.fetch_add(1, Ordering::Relaxed);
}
if let Ok(mut stats) = self.stats.lock() {
let entry = stats
.entry(query_key.to_string())
.or_insert_with(|| ExecutionStats::new(query_key));
entry.record(elapsed, success);
}
}
pub fn stats(&self, query_key: &str) -> Option<ExecutionStats> {
self.stats.lock().ok()?.get(query_key).cloned()
}
pub fn all_stats(&self) -> Vec<ExecutionStats> {
match self.stats.lock() {
Ok(stats) => stats.values().cloned().collect(),
Err(_) => Vec::new(),
}
}
pub fn global_total(&self) -> u64 {
self.global_total.load(Ordering::Relaxed)
}
pub fn global_failures(&self) -> u64 {
self.global_failures.load(Ordering::Relaxed)
}
pub fn global_error_rate(&self) -> f64 {
let total = self.global_total();
if total == 0 {
0.0
} else {
self.global_failures() as f64 / total as f64
}
}
pub fn slow_queries(&self, threshold_ms: f64) -> Vec<String> {
match self.stats.lock() {
Ok(stats) => stats
.values()
.filter(|s| s.avg_elapsed_ms() > threshold_ms)
.map(|s| s.query_key.clone())
.collect(),
Err(_) => Vec::new(),
}
}
pub fn reset(&self) {
if let Ok(mut stats) = self.stats.lock() {
stats.clear();
}
self.global_total.store(0, Ordering::Relaxed);
self.global_failures.store(0, Ordering::Relaxed);
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct CostFactors {
pub io_cost: f64,
pub cpu_cost: f64,
pub rows_cost: f64,
}
impl CostFactors {
pub fn total(&self) -> f64 {
self.io_cost + self.cpu_cost + self.rows_cost
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct CostEstimate {
pub table: String,
pub factors: CostFactors,
pub total_cost: f64,
}
#[derive(Debug, Clone)]
pub struct CostEstimator {
pub io_cost_per_page: f64,
pub cpu_cost_per_row: f64,
pub rows_cost_per_row: f64,
pub rows_per_page: u64,
}
impl Default for CostEstimator {
fn default() -> Self {
Self {
io_cost_per_page: 1.0,
cpu_cost_per_row: 0.1,
rows_cost_per_row: 0.05,
rows_per_page: 100,
}
}
}
impl CostEstimator {
pub fn new() -> Self {
Self::default()
}
pub fn estimate(&self, plan: &ExplainPlan) -> CostEstimate {
let rows = plan.rows as f64;
let pages = if self.rows_per_page > 0 {
plan.rows.div_ceil(self.rows_per_page)
} else {
plan.rows
} as f64;
let (io_cost, cpu_cost) = match plan.scan_type {
ScanType::FullTable => {
(pages * self.io_cost_per_page, rows * self.cpu_cost_per_row)
}
ScanType::IndexRange => {
(
pages * 0.3 * self.io_cost_per_page,
rows * 0.5 * self.cpu_cost_per_row,
)
}
ScanType::IndexLookup => {
(
3.0 * self.io_cost_per_page,
rows * 0.2 * self.cpu_cost_per_row,
)
}
ScanType::UniqueLookup => {
(1.0 * self.io_cost_per_page, 1.0 * self.cpu_cost_per_row)
}
ScanType::Other => (
pages * 0.5 * self.io_cost_per_page,
rows * 0.5 * self.cpu_cost_per_row,
),
};
let rows_cost = rows * self.rows_cost_per_row;
let factors = CostFactors {
io_cost,
cpu_cost,
rows_cost,
};
CostEstimate {
table: plan.table.clone(),
total_cost: factors.total(),
factors,
}
}
pub fn estimate_total(&self, plans: &[ExplainPlan]) -> f64 {
plans.iter().map(|p| self.estimate(p).total_cost).sum()
}
pub fn cost_delta(&self, baseline: &ExplainPlan, current: &ExplainPlan) -> f64 {
self.estimate(current).total_cost - self.estimate(baseline).total_cost
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum SuggestionKind {
AddIndex,
RewriteQuery,
PartitionTable,
AdjustJoinOrder,
LimitRows,
UseCoveringIndex,
}
impl SuggestionKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::AddIndex => "add-index",
Self::RewriteQuery => "rewrite-query",
Self::PartitionTable => "partition-table",
Self::AdjustJoinOrder => "adjust-join-order",
Self::LimitRows => "limit-rows",
Self::UseCoveringIndex => "use-covering-index",
}
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct OptimizationSuggestion {
pub kind: SuggestionKind,
pub table: String,
pub description: String,
pub estimated_improvement: f64,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct OptimizationReport {
pub query_key: String,
pub suggestions: Vec<OptimizationSuggestion>,
pub overall_score: u8,
}
impl OptimizationReport {
pub fn has_suggestions(&self) -> bool {
!self.suggestions.is_empty()
}
pub fn len(&self) -> usize {
self.suggestions.len()
}
pub fn is_empty(&self) -> bool {
self.suggestions.is_empty()
}
pub fn max_improvement(&self) -> f64 {
self.suggestions
.iter()
.map(|s| s.estimated_improvement)
.fold(0.0_f64, f64::max)
}
}
#[derive(Debug, Clone)]
pub struct OptimizationAdvisor {
analyzer: QueryPlanAnalyzer,
cost_estimator: CostEstimator,
}
impl Default for OptimizationAdvisor {
fn default() -> Self {
Self::new()
}
}
impl OptimizationAdvisor {
pub fn new() -> Self {
Self {
analyzer: QueryPlanAnalyzer::new(),
cost_estimator: CostEstimator::new(),
}
}
pub fn advise(&self, query_key: &str, plan: &ExplainPlan) -> OptimizationReport {
let issues = self.analyzer.analyze(plan);
let cost = self.cost_estimator.estimate(plan);
let mut suggestions = Vec::new();
for issue in &issues {
let (kind, improvement) = match issue.kind {
IssueKind::FullTableScan => {
let imp = if cost.total_cost > 0.0 {
1.0 - (plan.rows as f64 * 0.1 / cost.total_cost).min(1.0)
} else {
0.5
};
(SuggestionKind::AddIndex, imp)
}
IssueKind::MissingIndex => (SuggestionKind::AddIndex, 0.7),
IssueKind::LargeResult => (SuggestionKind::LimitRows, 0.4),
IssueKind::Filesort => (SuggestionKind::UseCoveringIndex, 0.5),
IssueKind::TemporaryTable => (SuggestionKind::RewriteQuery, 0.3),
IssueKind::HighCost => (SuggestionKind::PartitionTable, 0.6),
};
suggestions.push(OptimizationSuggestion {
kind,
table: plan.table.clone(),
description: issue.description.clone(),
estimated_improvement: improvement,
});
}
suggestions.sort_by(|a, b| {
b.estimated_improvement
.partial_cmp(&a.estimated_improvement)
.unwrap_or(std::cmp::Ordering::Equal)
});
let critical_count = issues
.iter()
.filter(|i| i.severity == IssueSeverity::Critical)
.count() as u8;
let warning_count = issues
.iter()
.filter(|i| i.severity == IssueSeverity::Warning)
.count() as u8;
let score = 100u8
.saturating_sub(critical_count.saturating_mul(20))
.saturating_sub(warning_count.saturating_mul(5));
OptimizationReport {
query_key: query_key.to_string(),
suggestions,
overall_score: score,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ComparisonResult {
pub regressions: Vec<PlanRegression>,
pub improvements: Vec<String>,
pub unchanged: Vec<String>,
}
impl ComparisonResult {
pub fn has_regressions(&self) -> bool {
!self.regressions.is_empty()
}
pub fn has_improvements(&self) -> bool {
!self.improvements.is_empty()
}
pub fn regression_count(&self) -> usize {
self.regressions.len()
}
}
#[derive(Debug, Clone)]
pub struct BaselineComparison {
pub rows_growth_factor: u64,
}
impl Default for BaselineComparison {
fn default() -> Self {
Self {
rows_growth_factor: 2,
}
}
}
impl BaselineComparison {
pub fn new() -> Self {
Self::default()
}
pub fn with_rows_growth_factor(mut self, factor: u64) -> Self {
self.rows_growth_factor = factor;
self
}
pub fn compare(&self, baseline: &PlanBaseline, current: &PlanBaseline) -> ComparisonResult {
let mut regressions = Vec::new();
let mut improvements = Vec::new();
let mut unchanged = Vec::new();
for (key, base_snap) in &baseline.snapshots {
if let Some(cur_snap) = current.snapshots.get(key) {
let regs = crate::regression::compare(
&base_snap.plan,
&cur_snap.plan,
key,
self.rows_growth_factor,
);
if regs.is_empty() {
if is_improved(&base_snap.plan, &cur_snap.plan) {
improvements.push(key.clone());
} else {
unchanged.push(key.clone());
}
} else {
regressions.extend(regs);
}
}
}
ComparisonResult {
regressions,
improvements,
unchanged,
}
}
pub fn compare_json(
&self,
baseline_json: &str,
current_json: &str,
) -> Result<ComparisonResult, serde_json::Error> {
let baseline: PlanBaseline = serde_json::from_str(baseline_json)?;
let current: PlanBaseline = serde_json::from_str(current_json)?;
Ok(self.compare(&baseline, ¤t))
}
}
fn is_improved(baseline: &ExplainPlan, current: &ExplainPlan) -> bool {
let base_severity = scan_severity(baseline.scan_type);
let cur_severity = scan_severity(current.scan_type);
cur_severity < base_severity || (cur_severity == base_severity && current.rows < baseline.rows)
}
fn scan_severity(scan: ScanType) -> u8 {
match scan {
ScanType::FullTable => 5,
ScanType::IndexRange => 4,
ScanType::IndexLookup => 3,
ScanType::UniqueLookup => 2,
ScanType::Other => 1,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn plan(
scan: ScanType,
table: &str,
index: Option<&str>,
rows: u64,
extra: Vec<String>,
) -> ExplainPlan {
ExplainPlan {
scan_type: scan,
table: table.to_string(),
index: index.map(|s| s.to_string()),
rows,
extra,
}
}
#[test]
fn analyzer_detects_full_table_scan() {
let analyzer = QueryPlanAnalyzer::new();
let p = plan(ScanType::FullTable, "users", None, 50000, vec![]);
let issues = analyzer.analyze(&p);
assert!(issues.iter().any(|i| i.kind == IssueKind::FullTableScan));
assert!(analyzer.has_critical(&p));
}
#[test]
fn analyzer_detects_large_result() {
let analyzer = QueryPlanAnalyzer::new().with_large_result_threshold(100);
let p = plan(ScanType::IndexLookup, "t", Some("idx"), 500, vec![]);
let issues = analyzer.analyze(&p);
assert!(issues.iter().any(|i| i.kind == IssueKind::LargeResult));
}
#[test]
fn analyzer_detects_filesort_and_temporary() {
let analyzer = QueryPlanAnalyzer::new();
let p = plan(
ScanType::IndexRange,
"t",
Some("idx"),
10,
vec!["Using filesort".into(), "Using temporary".into()],
);
let issues = analyzer.analyze(&p);
assert!(issues.iter().any(|i| i.kind == IssueKind::Filesort));
assert!(issues.iter().any(|i| i.kind == IssueKind::TemporaryTable));
}
#[test]
fn analyzer_clean_plan_has_no_issues() {
let analyzer = QueryPlanAnalyzer::new();
let p = plan(ScanType::UniqueLookup, "t", Some("pk"), 1, vec![]);
let issues = analyzer.analyze(&p);
assert!(
issues.is_empty(),
"clean plan should have no issues, got {:?}",
issues
);
}
#[test]
fn analyzer_issues_sorted_by_severity_desc() {
let analyzer = QueryPlanAnalyzer::new();
let p = plan(
ScanType::FullTable,
"t",
None,
50000,
vec!["Using filesort".into()],
);
let issues = analyzer.analyze(&p);
for w in issues.windows(2) {
assert!(
w[0].severity >= w[1].severity,
"issues should be sorted by severity desc"
);
}
}
#[test]
fn index_advisor_suggests_for_where_column() {
let advisor = IndexAdvisor::new();
let pattern = QueryPattern::new("users").with_where("email");
let advice = advisor.advise(&pattern).expect("should advise");
assert_eq!(advice.table, "users");
assert_eq!(advice.columns, vec!["email".to_string()]);
assert!(advice.benefit_level >= 1);
}
#[test]
fn index_advisor_suggests_composite_for_multi_columns() {
let advisor = IndexAdvisor::new();
let pattern = QueryPattern::new("orders")
.with_where("user_id")
.with_where("status")
.with_order_by("created_at");
let advice = advisor.advise(&pattern).expect("should advise");
assert_eq!(
advice.columns,
vec![
"user_id".to_string(),
"status".to_string(),
"created_at".to_string()
]
);
assert!(advice.benefit_level >= 2);
}
#[test]
fn index_advisor_skips_when_no_where_or_join() {
let advisor = IndexAdvisor::new();
let pattern = QueryPattern::new("t");
assert!(advisor.advise(&pattern).is_none());
}
#[test]
fn index_advisor_skips_when_index_exists() {
let advisor = IndexAdvisor::new().with_existing_index("users", vec!["email".into()]);
let pattern = QueryPattern::new("users").with_where("email");
assert!(
advisor.advise(&pattern).is_none(),
"should not suggest existing index"
);
}
#[test]
fn index_advisor_advise_many_dedupes() {
let advisor = IndexAdvisor::new();
let patterns = vec![
QueryPattern::new("t").with_where("a"),
QueryPattern::new("t").with_where("a"),
];
let advices = advisor.advise_many(&patterns);
assert_eq!(advices.len(), 1, "duplicate patterns should be deduped");
}
#[test]
fn execution_stats_record_and_query() {
let mut stats = ExecutionStats::new("q1");
stats.record(Duration::from_millis(10), true);
stats.record(Duration::from_millis(20), true);
stats.record(Duration::from_millis(5), false);
assert_eq!(stats.total_executions, 3);
assert_eq!(stats.success_count, 2);
assert_eq!(stats.failure_count, 1);
assert_eq!(stats.min_elapsed_ms, 5);
assert_eq!(stats.max_elapsed_ms, 20);
let avg = stats.avg_elapsed_ms();
assert!((11.0..=12.0).contains(&avg));
}
#[test]
fn execution_stats_error_rate() {
let mut stats = ExecutionStats::new("q1");
for _ in 0..7 {
stats.record(Duration::from_millis(1), true);
}
for _ in 0..3 {
stats.record(Duration::from_millis(1), false);
}
let rate = stats.error_rate();
assert!((0.29..=0.31).contains(&rate));
}
#[test]
fn execution_stats_collector_thread_safe() {
let collector = ExecutionStatsCollector::new();
collector.record("q1", Duration::from_millis(10), true);
collector.record("q1", Duration::from_millis(20), false);
collector.record("q2", Duration::from_millis(5), true);
assert_eq!(collector.global_total(), 3);
assert_eq!(collector.global_failures(), 1);
let q1 = collector.stats("q1").expect("q1 should exist");
assert_eq!(q1.total_executions, 2);
}
#[test]
fn execution_stats_collector_slow_queries() {
let collector = ExecutionStatsCollector::new();
for _ in 0..10 {
collector.record("fast", Duration::from_millis(1), true);
}
for _ in 0..5 {
collector.record("slow", Duration::from_millis(100), true);
}
let slow = collector.slow_queries(50.0);
assert_eq!(slow, vec!["slow".to_string()]);
}
#[test]
fn cost_estimator_full_table_scan_expensive() {
let estimator = CostEstimator::new();
let full = plan(ScanType::FullTable, "t", None, 10000, vec![]);
let lookup = plan(ScanType::UniqueLookup, "t", Some("pk"), 1, vec![]);
let full_cost = estimator.estimate(&full).total_cost;
let lookup_cost = estimator.estimate(&lookup).total_cost;
assert!(
full_cost > lookup_cost,
"full scan should be more expensive"
);
}
#[test]
fn cost_estimator_factors_decomposition() {
let estimator = CostEstimator::new();
let p = plan(ScanType::IndexRange, "t", Some("idx"), 500, vec![]);
let estimate = estimator.estimate(&p);
assert!(estimate.factors.io_cost > 0.0);
assert!(estimate.factors.cpu_cost > 0.0);
assert!(estimate.factors.rows_cost > 0.0);
let sum = estimate.factors.io_cost + estimate.factors.cpu_cost + estimate.factors.rows_cost;
assert!((sum - estimate.total_cost).abs() < 0.001);
}
#[test]
fn cost_estimator_total_for_multiple_plans() {
let estimator = CostEstimator::new();
let plans = vec![
plan(ScanType::FullTable, "a", None, 1000, vec![]),
plan(ScanType::UniqueLookup, "b", Some("pk"), 1, vec![]),
];
let total = estimator.estimate_total(&plans);
assert!(total > 0.0);
}
#[test]
fn cost_estimator_delta() {
let estimator = CostEstimator::new();
let base = plan(ScanType::FullTable, "t", None, 10000, vec![]);
let cur = plan(ScanType::UniqueLookup, "t", Some("pk"), 1, vec![]);
let delta = estimator.cost_delta(&base, &cur);
assert!(delta < 0.0, "current should be cheaper than baseline");
}
#[test]
fn optimization_advisor_generates_suggestions_for_full_scan() {
let advisor = OptimizationAdvisor::new();
let p = plan(ScanType::FullTable, "t", None, 50000, vec![]);
let report = advisor.advise("q1", &p);
assert!(report.has_suggestions());
assert!(report
.suggestions
.iter()
.any(|s| s.kind == SuggestionKind::AddIndex));
assert!(report.overall_score < 100);
}
#[test]
fn optimization_advisor_clean_plan_high_score() {
let advisor = OptimizationAdvisor::new();
let p = plan(ScanType::UniqueLookup, "t", Some("pk"), 1, vec![]);
let report = advisor.advise("q1", &p);
assert!(!report.has_suggestions());
assert_eq!(report.overall_score, 100);
}
#[test]
fn optimization_advisor_suggestions_sorted_by_improvement() {
let advisor = OptimizationAdvisor::new();
let p = plan(
ScanType::FullTable,
"t",
None,
50000,
vec!["Using filesort".into()],
);
let report = advisor.advise("q1", &p);
for w in report.suggestions.windows(2) {
assert!(
w[0].estimated_improvement >= w[1].estimated_improvement,
"suggestions should be sorted by improvement desc"
);
}
}
#[test]
fn optimization_report_max_improvement() {
let advisor = OptimizationAdvisor::new();
let p = plan(ScanType::FullTable, "t", None, 50000, vec![]);
let report = advisor.advise("q1", &p);
let max = report.max_improvement();
assert!(max > 0.0);
}
use crate::regression::PlanSnapshot;
fn make_baseline(query_key: &str, p: ExplainPlan) -> PlanBaseline {
let mut baseline = PlanBaseline::default();
baseline.upsert(PlanSnapshot::new(query_key, p));
baseline
}
#[test]
fn baseline_comparison_detects_regression() {
let cmp = BaselineComparison::new();
let base = make_baseline(
"q1",
plan(ScanType::IndexRange, "t", Some("idx"), 100, vec![]),
);
let cur = make_baseline("q1", plan(ScanType::FullTable, "t", None, 50000, vec![]));
let result = cmp.compare(&base, &cur);
assert!(result.has_regressions());
assert!(result.regression_count() > 0);
}
#[test]
fn baseline_comparison_detects_improvement() {
let cmp = BaselineComparison::new();
let base = make_baseline("q1", plan(ScanType::FullTable, "t", None, 1000, vec![]));
let cur = make_baseline(
"q1",
plan(ScanType::UniqueLookup, "t", Some("pk"), 1, vec![]),
);
let result = cmp.compare(&base, &cur);
assert!(result.has_improvements());
assert!(result.improvements.contains(&"q1".to_string()));
}
#[test]
fn baseline_comparison_unchanged() {
let cmp = BaselineComparison::new();
let p = plan(ScanType::IndexRange, "t", Some("idx"), 100, vec![]);
let base = make_baseline("q1", p.clone());
let cur = make_baseline("q1", p);
let result = cmp.compare(&base, &cur);
assert!(!result.has_regressions());
assert!(!result.has_improvements());
assert!(result.unchanged.contains(&"q1".to_string()));
}
#[test]
fn baseline_comparison_json_roundtrip() {
let cmp = BaselineComparison::new().with_rows_growth_factor(3);
let base = make_baseline(
"q1",
plan(ScanType::IndexRange, "t", Some("idx"), 100, vec![]),
);
let cur = make_baseline("q1", plan(ScanType::FullTable, "t", None, 1000, vec![]));
let result = cmp
.compare_json(&base.to_json().unwrap(), &cur.to_json().unwrap())
.unwrap();
assert!(result.has_regressions());
}
}