use fraiseql_core::schema::{PaginationOrder, QueryDefinition};
use fraiseql_db::postgres::IndexInfo;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Advice {
TieBreakUnindexed {
tie_break: String,
},
MissingComposite {
sort_keys: Vec<String>,
tie_break: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QueryAdvice {
pub query: String,
pub relation: String,
pub advice: Advice,
pub ddl: Vec<String>,
}
#[must_use]
pub fn tie_break_key(query: &QueryDefinition) -> Option<String> {
match query.pagination_order.as_ref()? {
PaginationOrder::Column(column) => Some(column.clone()),
PaginationOrder::Identity => Some("id".to_string()),
}
}
pub(crate) fn keys_match(a: &str, b: &str) -> bool {
fn normalize(key: &str) -> String {
key.to_lowercase()
.replace("::text", "")
.chars()
.filter(|c| !c.is_whitespace())
.collect()
}
normalize(a) == normalize(b)
}
fn leads_with(indexes: &[IndexInfo], key: &str) -> bool {
indexes.iter().any(|ix| ix.keys.first().is_some_and(|k| keys_match(k, key)))
}
fn leads_with_pair(indexes: &[IndexInfo], first: &str, second: &str) -> bool {
indexes.iter().any(|ix| {
ix.keys.len() >= 2 && keys_match(&ix.keys[0], first) && keys_match(&ix.keys[1], second)
})
}
#[must_use]
pub fn advise(query: &QueryDefinition, relation: &str, indexes: &[IndexInfo]) -> Vec<QueryAdvice> {
let Some(tie_break) = tie_break_key(query) else {
return Vec::new();
};
let mut findings = Vec::new();
if !leads_with(indexes, &tie_break) {
findings.push(QueryAdvice {
query: query.name.clone(),
relation: relation.to_string(),
advice: Advice::TieBreakUnindexed {
tie_break: tie_break.clone(),
},
ddl: vec![format!("CREATE INDEX ON {relation} ({});", tie_break)],
});
}
if query.auto_params.has_order_by {
let mut sort_keys: Vec<String> = query
.native_columns
.keys()
.map(|key| fraiseql_core::utils::to_snake_case(key))
.filter(|column| !keys_match(column, &tie_break))
.filter(|column| leads_with(indexes, column))
.filter(|column| !leads_with_pair(indexes, column, &tie_break))
.collect();
sort_keys.sort_unstable();
sort_keys.dedup();
if !sort_keys.is_empty() {
let ddl = sort_keys
.iter()
.map(|k| format!("CREATE INDEX ON {relation} ({k}, {});", tie_break))
.collect();
findings.push(QueryAdvice {
query: query.name.clone(),
relation: relation.to_string(),
advice: Advice::MissingComposite {
sort_keys,
tie_break,
},
ddl,
});
}
}
findings
}
#[cfg(test)]
mod tests;