use serde::Serialize;
use sqlx::PgPool;
use std::collections::{HashMap, HashSet};
use crate::error::Result;
use crate::query_logger::{QueryLogger, QueryStats};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub enum Priority {
Low,
Medium,
High,
Critical,
}
#[derive(Debug, Clone, Serialize)]
pub enum IndexSuggestionType {
SingleColumn {
table: String,
column: String,
},
Composite {
table: String,
columns: Vec<String>,
},
Partial {
table: String,
column: String,
condition: String,
},
Covering {
table: String,
index_columns: Vec<String>,
include_columns: Vec<String>,
},
FullText {
table: String,
column: String,
},
Brin {
table: String,
column: String,
},
}
#[derive(Debug, Clone, Serialize)]
pub struct IndexSuggestion {
pub suggestion_type: IndexSuggestionType,
pub priority: Priority,
pub impact: String,
pub create_sql: String,
pub affected_query_count: usize,
pub total_time_affected_ms: u64,
pub reasoning: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ExistingIndex {
pub name: String,
pub table: String,
pub columns: Vec<String>,
pub is_unique: bool,
pub is_primary: bool,
pub index_type: String,
pub size_bytes: i64,
pub index_scans: i64,
pub tuples_read: i64,
pub tuples_fetched: i64,
}
#[derive(Debug, Clone, Serialize)]
pub struct UnusedIndex {
pub index: ExistingIndex,
pub size_bytes: i64,
pub recommendation: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct IndexAnalysis {
pub suggestions: Vec<IndexSuggestion>,
pub existing_indexes: Vec<ExistingIndex>,
pub unused_indexes: Vec<UnusedIndex>,
pub duplicate_indexes: Vec<(ExistingIndex, ExistingIndex)>,
pub health_score: u32,
pub summary: AnalysisSummary,
}
#[derive(Debug, Clone, Serialize)]
pub struct AnalysisSummary {
pub total_indexes: usize,
pub unused_count: usize,
pub duplicate_count: usize,
pub suggestion_count: usize,
pub critical_count: usize,
pub wasted_space_bytes: i64,
pub recommendations: Vec<String>,
}
pub struct IndexAnalyzer {
pub min_scans_threshold: i64,
pub min_query_calls: u64,
pub min_avg_duration_ms: f64,
}
impl Default for IndexAnalyzer {
fn default() -> Self {
Self {
min_scans_threshold: 10,
min_query_calls: 5,
min_avg_duration_ms: 10.0,
}
}
}
impl IndexAnalyzer {
pub fn new() -> Self {
Self::default()
}
pub fn with_min_scans(mut self, threshold: i64) -> Self {
self.min_scans_threshold = threshold;
self
}
pub async fn analyze(
&self,
pool: &PgPool,
query_logger: Option<&QueryLogger>,
) -> Result<IndexAnalysis> {
let existing_indexes = self.get_existing_indexes(pool).await?;
let index_usage = self.get_index_usage_stats(pool).await?;
let unused_indexes = self.find_unused_indexes(&existing_indexes, &index_usage);
let duplicate_indexes = self.find_duplicate_indexes(&existing_indexes);
let suggestions = if let Some(logger) = query_logger {
self.generate_suggestions_from_queries(logger, &existing_indexes)
} else {
Vec::new()
};
let health_score = self.calculate_health_score(
&existing_indexes,
&unused_indexes,
&duplicate_indexes,
&suggestions,
);
let wasted_space: i64 = unused_indexes.iter().map(|u| u.size_bytes).sum();
let critical_count = suggestions
.iter()
.filter(|s| s.priority == Priority::Critical)
.count();
let mut recommendations = Vec::new();
if !unused_indexes.is_empty() {
recommendations.push(format!(
"Consider dropping {} unused indexes to save {} bytes",
unused_indexes.len(),
wasted_space
));
}
if !duplicate_indexes.is_empty() {
recommendations.push(format!(
"Found {} duplicate index pairs - consider consolidating",
duplicate_indexes.len()
));
}
if critical_count > 0 {
recommendations.push(format!(
"{} critical index suggestions - address these first",
critical_count
));
}
let summary = AnalysisSummary {
total_indexes: existing_indexes.len(),
unused_count: unused_indexes.len(),
duplicate_count: duplicate_indexes.len(),
suggestion_count: suggestions.len(),
critical_count,
wasted_space_bytes: wasted_space,
recommendations,
};
Ok(IndexAnalysis {
suggestions,
existing_indexes,
unused_indexes,
duplicate_indexes,
health_score,
summary,
})
}
async fn get_existing_indexes(&self, pool: &PgPool) -> Result<Vec<ExistingIndex>> {
let rows = sqlx::query_as::<_, (String, String, String, bool, bool, String, i64)>(
r#"
SELECT
i.relname as index_name,
t.relname as table_name,
array_to_string(array_agg(a.attname ORDER BY k.n), ', ') as columns,
ix.indisunique as is_unique,
ix.indisprimary as is_primary,
am.amname as index_type,
pg_relation_size(i.oid) as size_bytes
FROM pg_index ix
JOIN pg_class i ON i.oid = ix.indexrelid
JOIN pg_class t ON t.oid = ix.indrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
JOIN pg_am am ON am.oid = i.relam
CROSS JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, n)
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
WHERE n.nspname = 'public'
GROUP BY i.relname, t.relname, ix.indisunique, ix.indisprimary, am.amname, i.oid
ORDER BY t.relname, i.relname
"#,
)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(
|(name, table, columns, is_unique, is_primary, index_type, size_bytes)| {
ExistingIndex {
name,
table,
columns: columns.split(", ").map(String::from).collect(),
is_unique,
is_primary,
index_type,
size_bytes,
index_scans: 0,
tuples_read: 0,
tuples_fetched: 0,
}
},
)
.collect())
}
async fn get_index_usage_stats(
&self,
pool: &PgPool,
) -> Result<HashMap<String, (i64, i64, i64)>> {
let rows = sqlx::query_as::<_, (String, i64, i64, i64)>(
r#"
SELECT
indexrelname as index_name,
COALESCE(idx_scan, 0) as index_scans,
COALESCE(idx_tup_read, 0) as tuples_read,
COALESCE(idx_tup_fetch, 0) as tuples_fetched
FROM pg_stat_user_indexes
"#,
)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|(name, scans, read, fetched)| (name, (scans, read, fetched)))
.collect())
}
fn find_unused_indexes(
&self,
indexes: &[ExistingIndex],
usage: &HashMap<String, (i64, i64, i64)>,
) -> Vec<UnusedIndex> {
indexes
.iter()
.filter_map(|idx| {
if idx.is_primary {
return None;
}
let (scans, _, _) = usage.get(&idx.name).copied().unwrap_or((0, 0, 0));
if scans < self.min_scans_threshold {
let recommendation = if scans == 0 {
"Index has never been used - safe to drop".to_string()
} else {
format!(
"Index has only {} scans - consider dropping if not needed for constraints",
scans
)
};
Some(UnusedIndex {
index: idx.clone(),
size_bytes: idx.size_bytes,
recommendation,
})
} else {
None
}
})
.collect()
}
fn find_duplicate_indexes(
&self,
indexes: &[ExistingIndex],
) -> Vec<(ExistingIndex, ExistingIndex)> {
let mut duplicates = Vec::new();
let mut seen: HashMap<(String, Vec<String>), ExistingIndex> = HashMap::new();
for idx in indexes {
if idx.is_unique || idx.is_primary {
continue;
}
let key = (idx.table.clone(), idx.columns.clone());
if let Some(existing) = seen.get(&key) {
duplicates.push((existing.clone(), idx.clone()));
} else {
seen.insert(key, idx.clone());
}
}
duplicates
}
fn generate_suggestions_from_queries(
&self,
logger: &QueryLogger,
existing_indexes: &[ExistingIndex],
) -> Vec<IndexSuggestion> {
let stats = logger.get_stats();
let mut suggestions = Vec::new();
let mut indexed_columns: HashMap<String, HashSet<Vec<String>>> = HashMap::new();
for idx in existing_indexes {
indexed_columns
.entry(idx.table.clone())
.or_default()
.insert(idx.columns.clone());
}
for stat in &stats {
if stat.call_count < self.min_query_calls
|| stat.avg_duration_ms < self.min_avg_duration_ms
{
continue;
}
if let Some(suggestion) = self.analyze_query_for_index(stat, &indexed_columns) {
suggestions.push(suggestion);
}
}
suggestions.sort_by(|a, b| b.priority.cmp(&a.priority));
suggestions
}
fn analyze_query_for_index(
&self,
stat: &QueryStats,
indexed_columns: &HashMap<String, HashSet<Vec<String>>>,
) -> Option<IndexSuggestion> {
let query = stat.query_preview.to_uppercase();
let (table, conditions) = self.extract_query_info(&query)?;
let existing = indexed_columns.get(&table);
let condition_columns: Vec<String> = conditions.iter().map(|c| c.to_lowercase()).collect();
if let Some(indexes) = existing {
for idx_cols in indexes {
if condition_columns.iter().all(|c| idx_cols.contains(c)) {
return None; }
}
}
let priority = if stat.slow_count > 0 && stat.avg_duration_ms > 100.0 {
Priority::Critical
} else if stat.avg_duration_ms > 50.0 || stat.call_count > 100 {
Priority::High
} else if stat.avg_duration_ms > 20.0 || stat.call_count > 50 {
Priority::Medium
} else {
Priority::Low
};
let (suggestion_type, create_sql, reasoning) = if condition_columns.len() == 1 {
let col = &condition_columns[0];
(
IndexSuggestionType::SingleColumn {
table: table.clone(),
column: col.clone(),
},
format!(
"CREATE INDEX CONCURRENTLY idx_{}_{} ON {} ({});",
table, col, table, col
),
format!(
"Query frequently filters on {} with avg {}ms execution time",
col, stat.avg_duration_ms as u32
),
)
} else {
let cols_str = condition_columns.join(", ");
let cols_name = condition_columns.join("_");
(
IndexSuggestionType::Composite {
table: table.clone(),
columns: condition_columns.clone(),
},
format!(
"CREATE INDEX CONCURRENTLY idx_{}_{} ON {} ({});",
table, cols_name, table, cols_str
),
format!(
"Query frequently filters on multiple columns ({}) with avg {}ms execution time",
cols_str, stat.avg_duration_ms as u32
),
)
};
let impact = format!(
"Could improve {} queries totaling {}ms execution time",
stat.call_count, stat.total_duration_ms
);
Some(IndexSuggestion {
suggestion_type,
priority,
impact,
create_sql,
affected_query_count: 1,
total_time_affected_ms: stat.total_duration_ms,
reasoning,
})
}
fn extract_query_info(&self, query: &str) -> Option<(String, Vec<String>)> {
let from_pos = query.find("FROM ")?;
let after_from = &query[from_pos + 5..];
let table_end = after_from.find(|c: char| c.is_whitespace() || c == ',')?;
let table = after_from[..table_end].trim().to_lowercase();
if table.starts_with("pg_") || table.starts_with("information_schema") {
return None;
}
let mut conditions = Vec::new();
if let Some(where_pos) = query.find("WHERE ") {
let where_clause = &query[where_pos + 6..];
let parts: Vec<&str> = where_clause.split(['=', '<', '>', ' ']).collect();
for part in parts {
let trimmed = part.trim();
if !trimmed.is_empty()
&& trimmed.chars().all(|c| c.is_alphanumeric() || c == '_')
&& ![
"AND", "OR", "NOT", "IN", "IS", "NULL", "TRUE", "FALSE", "LIKE",
]
.contains(&trimmed)
{
let col = trimmed.to_lowercase();
if !conditions.contains(&col) {
conditions.push(col);
}
}
}
}
if conditions.is_empty() {
return None;
}
Some((table, conditions))
}
fn calculate_health_score(
&self,
existing: &[ExistingIndex],
unused: &[UnusedIndex],
duplicates: &[(ExistingIndex, ExistingIndex)],
suggestions: &[IndexSuggestion],
) -> u32 {
let mut score = 100u32;
let unused_ratio = unused.len() as f32 / existing.len().max(1) as f32;
score = score.saturating_sub((unused_ratio * 20.0) as u32);
score = score.saturating_sub((duplicates.len() * 5) as u32);
let critical_count = suggestions
.iter()
.filter(|s| s.priority == Priority::Critical)
.count();
score = score.saturating_sub((critical_count * 10) as u32);
let high_count = suggestions
.iter()
.filter(|s| s.priority == Priority::High)
.count();
score = score.saturating_sub((high_count * 5) as u32);
score
}
pub fn generate_migration_script(suggestions: &[IndexSuggestion]) -> String {
let mut script = String::from("-- Index optimization migration\n");
script.push_str("-- Generated by IndexAnalyzer\n\n");
for (i, suggestion) in suggestions.iter().enumerate() {
script.push_str(&format!(
"-- Suggestion {}: {:?} priority\n",
i + 1,
suggestion.priority
));
script.push_str(&format!("-- Reasoning: {}\n", suggestion.reasoning));
script.push_str(&format!("-- Impact: {}\n", suggestion.impact));
script.push_str(&suggestion.create_sql);
script.push_str("\n\n");
}
script
}
pub fn generate_cleanup_script(unused: &[UnusedIndex]) -> String {
let mut script = String::from("-- Unused index cleanup\n");
script.push_str("-- Review each index before dropping!\n\n");
for unused_idx in unused {
script.push_str(&format!("-- {}\n", unused_idx.recommendation));
script.push_str(&format!("-- Size: {} bytes\n", unused_idx.size_bytes));
script.push_str(&format!(
"DROP INDEX IF EXISTS {};\n\n",
unused_idx.index.name
));
}
script
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_query_info() {
let analyzer = IndexAnalyzer::new();
let query = "SELECT * FROM users WHERE email = $1";
let result = analyzer.extract_query_info(&query.to_uppercase());
assert!(result.is_some());
let (table, conditions) = result.unwrap();
assert_eq!(table, "users");
assert!(conditions.contains(&"email".to_string()));
}
#[test]
fn test_extract_composite_conditions() {
let analyzer = IndexAnalyzer::new();
let query = "SELECT * FROM orders WHERE user_id = $1 AND status = $2";
let result = analyzer.extract_query_info(&query.to_uppercase());
assert!(result.is_some());
let (table, conditions) = result.unwrap();
assert_eq!(table, "orders");
assert!(conditions.contains(&"user_id".to_string()));
assert!(conditions.contains(&"status".to_string()));
}
#[test]
fn test_find_duplicates() {
let analyzer = IndexAnalyzer::new();
let indexes = vec![
ExistingIndex {
name: "idx_users_email".to_string(),
table: "users".to_string(),
columns: vec!["email".to_string()],
is_unique: false,
is_primary: false,
index_type: "btree".to_string(),
size_bytes: 1000,
index_scans: 100,
tuples_read: 1000,
tuples_fetched: 1000,
},
ExistingIndex {
name: "idx_users_email_2".to_string(),
table: "users".to_string(),
columns: vec!["email".to_string()],
is_unique: false,
is_primary: false,
index_type: "btree".to_string(),
size_bytes: 1000,
index_scans: 50,
tuples_read: 500,
tuples_fetched: 500,
},
];
let duplicates = analyzer.find_duplicate_indexes(&indexes);
assert_eq!(duplicates.len(), 1);
}
#[test]
fn test_priority_ordering() {
assert!(Priority::Critical > Priority::High);
assert!(Priority::High > Priority::Medium);
assert!(Priority::Medium > Priority::Low);
}
}