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,
}))
}
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
}
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
}
fn is_db_explain_enabled(debug_config: Option<&fraiseql_core::schema::DebugConfig>) -> bool {
debug_config.is_some_and(|c| c.enabled && c.database_explain)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_warnings_deep() {
let complexity = ComplexityInfo {
depth: 15,
complexity: 10,
alias_count: 0,
};
let warnings = generate_warnings(&complexity);
assert!(!warnings.is_empty());
assert!(warnings[0].contains("depth"));
}
#[test]
fn test_generate_warnings_high_complexity() {
let complexity = ComplexityInfo {
depth: 3,
complexity: 200,
alias_count: 0,
};
let warnings = generate_warnings(&complexity);
assert!(!warnings.is_empty());
assert!(warnings.iter().any(|w| w.contains("complexity")));
}
#[test]
fn test_generate_warnings_high_alias_count() {
let complexity = ComplexityInfo {
depth: 2,
complexity: 5,
alias_count: 35,
};
let warnings = generate_warnings(&complexity);
assert!(warnings.iter().any(|w| w.contains("alias")));
}
#[test]
fn test_estimate_cost() {
let complexity = ComplexityInfo {
depth: 2,
complexity: 3,
alias_count: 0,
};
let cost = estimate_cost(&complexity);
assert!(cost > 0);
}
#[test]
fn test_stats_response_structure() {
let response = StatsResponse {
total_queries: 100,
successful_queries: 95,
failed_queries: 5,
average_latency_ms: 42.5,
};
assert_eq!(response.total_queries, 100);
assert_eq!(response.successful_queries, 95);
assert_eq!(response.failed_queries, 5);
assert!(response.average_latency_ms > 0.0);
}
#[test]
fn test_explain_response_structure() {
let response = ExplainResponse {
query: "query { users { id } }".to_string(),
sql: Some("SELECT id FROM users".to_string()),
complexity: ComplexityInfo {
depth: 2,
complexity: 2,
alias_count: 0,
},
warnings: vec![],
estimated_cost: 50,
views_accessed: vec!["v_user".to_string()],
query_type: "regular".to_string(),
database_plan: None,
};
assert!(!response.query.is_empty());
assert_eq!(response.sql.as_deref(), Some("SELECT id FROM users"));
assert_eq!(response.complexity.depth, 2);
assert_eq!(response.estimated_cost, 50);
}
#[test]
fn test_validate_request_structure() {
let request = ValidateRequest {
query: "query { users { id } }".to_string(),
};
assert!(!request.query.is_empty());
}
#[test]
fn test_explain_request_structure() {
let request = ExplainRequest {
query: "query { users { id } }".to_string(),
variables: None,
};
assert!(!request.query.is_empty());
}
#[test]
fn test_debug_disabled_no_db_explain() {
use fraiseql_core::schema::DebugConfig;
assert!(!is_db_explain_enabled(None));
let config = DebugConfig {
enabled: true,
database_explain: false,
..Default::default()
};
assert!(!is_db_explain_enabled(Some(&config)));
}
#[test]
fn test_debug_enabled_db_explain() {
use fraiseql_core::schema::DebugConfig;
let config = DebugConfig {
enabled: true,
database_explain: true,
..Default::default()
};
assert!(is_db_explain_enabled(Some(&config)));
}
#[test]
fn test_debug_master_switch_required() {
use fraiseql_core::schema::DebugConfig;
let config = DebugConfig {
enabled: false,
database_explain: true,
..Default::default()
};
assert!(!is_db_explain_enabled(Some(&config)));
}
}