use axum::{Json, extract::State};
use fraiseql_core::{db::traits::DatabaseAdapter, graphql::DEFAULT_MAX_ALIASES};
use serde::{Deserialize, Serialize};
use crate::{
routes::{
api::types::{ApiError, ApiResponse},
graphql::AppState,
},
validation::RequestValidator,
};
#[derive(Debug, Deserialize)]
pub struct ExplainRequest {
pub query: String,
#[serde(default)]
pub variables: Option<serde_json::Value>,
}
#[derive(Debug, Serialize)]
pub struct ExplainResponse {
pub query: String,
pub sql: Option<String>,
pub complexity: ComplexityInfo,
pub warnings: Vec<String>,
pub estimated_cost: usize,
pub views_accessed: Vec<String>,
pub query_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub database_plan: Option<serde_json::Value>,
}
#[derive(Debug, Serialize, Clone, Copy)]
pub struct ComplexityInfo {
pub depth: usize,
pub complexity: usize,
pub alias_count: usize,
}
#[derive(Debug, Deserialize)]
pub struct ValidateRequest {
pub query: String,
}
#[derive(Debug, Serialize)]
pub struct ValidateResponse {
pub valid: bool,
pub errors: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct StatsResponse {
pub total_queries: usize,
pub successful_queries: usize,
pub failed_queries: usize,
pub average_latency_ms: f64,
}
pub async fn explain_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
State(state): State<AppState<A>>,
Json(req): Json<ExplainRequest>,
) -> Result<Json<ApiResponse<ExplainResponse>>, ApiError> {
if req.query.trim().is_empty() {
return Err(ApiError::validation_error("Query cannot be empty"));
}
let validator = RequestValidator::default();
let metrics = validator
.analyze(&req.query)
.map_err(|e| ApiError::validation_error(format!("Query parse error: {e}")))?;
let complexity = ComplexityInfo {
depth: metrics.depth,
complexity: metrics.complexity,
alias_count: metrics.alias_count,
};
let warnings = generate_warnings(&complexity);
let executor = state.executor();
let (sql, estimated_cost, views_accessed, query_type, database_plan) =
match executor.plan_query(&req.query, req.variables.as_ref()) {
Ok(plan) => {
let db_plan =
if is_db_explain_enabled(state.debug_config.as_ref()) && !plan.sql.is_empty() {
executor
.adapter()
.explain_query(&plan.sql, &[])
.await
.inspect_err(|e| tracing::warn!(error = %e, "EXPLAIN query failed"))
.ok()
} else {
None
};
(
if plan.sql.is_empty() {
None
} else {
Some(plan.sql)
},
plan.estimated_cost,
plan.views_accessed,
plan.query_type,
db_plan,
)
},
Err(_) => {
(None, estimate_cost(&complexity), Vec::new(), "unknown".to_string(), None)
},
};
let response = ExplainResponse {
query: req.query,
sql,
complexity,
warnings,
estimated_cost,
views_accessed,
query_type,
database_plan,
};
Ok(Json(ApiResponse {
status: "success".to_string(),
data: response,
}))
}
pub async fn validate_handler<A: DatabaseAdapter>(
State(_state): State<AppState<A>>,
Json(req): Json<ValidateRequest>,
) -> Result<Json<ApiResponse<ValidateResponse>>, ApiError> {
if req.query.trim().is_empty() {
return Ok(Json(ApiResponse {
status: "success".to_string(),
data: ValidateResponse {
valid: false,
errors: vec!["Query cannot be empty".to_string()],
},
}));
}
let (valid, errors) = match graphql_parser::parse_query::<String>(&req.query) {
Ok(_) => (true, vec![]),
Err(e) => (false, vec![e.to_string()]),
};
let response = ValidateResponse { valid, errors };
Ok(Json(ApiResponse {
status: "success".to_string(),
data: response,
}))
}
pub async fn stats_handler<A: DatabaseAdapter>(
State(state): State<AppState<A>>,
) -> Result<Json<ApiResponse<StatsResponse>>, ApiError> {
let total_queries = state.metrics.queries_total.load(std::sync::atomic::Ordering::Relaxed);
let successful_queries =
state.metrics.queries_success.load(std::sync::atomic::Ordering::Relaxed);
let failed_queries = state.metrics.queries_error.load(std::sync::atomic::Ordering::Relaxed);
let total_duration_us =
state.metrics.queries_duration_us.load(std::sync::atomic::Ordering::Relaxed);
#[allow(clippy::cast_precision_loss)]
let average_latency_ms = if total_queries > 0 {
(total_duration_us as f64 / total_queries as f64) / 1000.0
} else {
0.0
};
#[allow(clippy::cast_possible_truncation)]
let response = StatsResponse {
total_queries: total_queries as usize,
successful_queries: successful_queries as usize,
failed_queries: failed_queries as usize,
average_latency_ms,
};
Ok(Json(ApiResponse {
status: "success".to_string(),
data: response,
}))
}
pub(crate) fn generate_warnings(complexity: &ComplexityInfo) -> Vec<String> {
let mut warnings = vec![];
if complexity.depth > 10 {
warnings.push(format!(
"Query nesting depth is {} (threshold: 10). Consider using aliases or fragments.",
complexity.depth
));
}
if complexity.complexity > 100 {
warnings.push(format!(
"Query complexity score is {} (threshold: 100). This may take longer to execute.",
complexity.complexity
));
}
if complexity.alias_count > DEFAULT_MAX_ALIASES {
warnings.push(format!(
"Query has {} aliases (threshold: {DEFAULT_MAX_ALIASES}). High alias counts may indicate amplification.",
complexity.alias_count
));
}
warnings
}
pub(crate) const fn estimate_cost(complexity: &ComplexityInfo) -> usize {
let base_cost = 50;
let depth_cost = complexity.depth.saturating_mul(10);
let complexity_cost = complexity.complexity.saturating_mul(5);
base_cost + depth_cost + complexity_cost
}
pub(crate) fn is_db_explain_enabled(
debug_config: Option<&fraiseql_core::schema::DebugConfig>,
) -> bool {
debug_config.is_some_and(|c| c.enabled && c.database_explain)
}