fraiseql_server/routes/api/
query.rs1use axum::{Json, extract::State};
9use fraiseql_core::{db::traits::DatabaseAdapter, graphql::DEFAULT_MAX_ALIASES};
10use serde::{Deserialize, Serialize};
11
12use crate::{
13 routes::{
14 api::types::{ApiError, ApiResponse},
15 graphql::AppState,
16 },
17 validation::RequestValidator,
18};
19
20#[derive(Debug, Deserialize)]
22pub struct ExplainRequest {
23 pub query: String,
25 #[serde(default)]
27 pub variables: Option<serde_json::Value>,
28}
29
30#[derive(Debug, Serialize)]
32pub struct ExplainResponse {
33 pub query: String,
35 pub sql: Option<String>,
37 pub complexity: ComplexityInfo,
39 pub warnings: Vec<String>,
41 pub estimated_cost: usize,
43 pub views_accessed: Vec<String>,
45 pub query_type: String,
47 #[serde(skip_serializing_if = "Option::is_none")]
49 pub database_plan: Option<serde_json::Value>,
50}
51
52#[derive(Debug, Serialize, Clone, Copy)]
57pub struct ComplexityInfo {
58 pub depth: usize,
60 pub complexity: usize,
62 pub alias_count: usize,
64}
65
66#[derive(Debug, Deserialize)]
68pub struct ValidateRequest {
69 pub query: String,
71}
72
73#[derive(Debug, Serialize)]
75pub struct ValidateResponse {
76 pub valid: bool,
78 pub errors: Vec<String>,
80}
81
82#[derive(Debug, Serialize)]
84pub struct StatsResponse {
85 pub total_queries: usize,
87 pub successful_queries: usize,
89 pub failed_queries: usize,
91 pub average_latency_ms: f64,
93}
94
95pub async fn explain_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
107 State(state): State<AppState<A>>,
108 Json(req): Json<ExplainRequest>,
109) -> Result<Json<ApiResponse<ExplainResponse>>, ApiError> {
110 if req.query.trim().is_empty() {
112 return Err(ApiError::validation_error("Query cannot be empty"));
113 }
114
115 let validator = RequestValidator::default();
117 let metrics = validator
118 .analyze(&req.query)
119 .map_err(|e| ApiError::validation_error(format!("Query parse error: {e}")))?;
120
121 let complexity = ComplexityInfo {
122 depth: metrics.depth,
123 complexity: metrics.complexity,
124 alias_count: metrics.alias_count,
125 };
126
127 let warnings = generate_warnings(&complexity);
129
130 let executor = state.executor();
132 let (sql, estimated_cost, views_accessed, query_type, database_plan) =
133 match executor.plan_query(&req.query, req.variables.as_ref()) {
134 Ok(plan) => {
135 let db_plan =
137 if is_db_explain_enabled(state.debug_config.as_ref()) && !plan.sql.is_empty() {
138 executor
139 .adapter()
140 .explain_query(&plan.sql, &[])
141 .await
142 .inspect_err(|e| tracing::warn!(error = %e, "EXPLAIN query failed"))
143 .ok()
144 } else {
145 None
146 };
147
148 (
149 if plan.sql.is_empty() {
150 None
151 } else {
152 Some(plan.sql)
153 },
154 plan.estimated_cost,
155 plan.views_accessed,
156 plan.query_type,
157 db_plan,
158 )
159 },
160 Err(_) => {
161 (None, estimate_cost(&complexity), Vec::new(), "unknown".to_string(), None)
164 },
165 };
166
167 let response = ExplainResponse {
168 query: req.query,
169 sql,
170 complexity,
171 warnings,
172 estimated_cost,
173 views_accessed,
174 query_type,
175 database_plan,
176 };
177
178 Ok(Json(ApiResponse {
179 status: "success".to_string(),
180 data: response,
181 }))
182}
183
184pub async fn validate_handler<A: DatabaseAdapter>(
193 State(_state): State<AppState<A>>,
194 Json(req): Json<ValidateRequest>,
195) -> Result<Json<ApiResponse<ValidateResponse>>, ApiError> {
196 if req.query.trim().is_empty() {
197 return Ok(Json(ApiResponse {
198 status: "success".to_string(),
199 data: ValidateResponse {
200 valid: false,
201 errors: vec!["Query cannot be empty".to_string()],
202 },
203 }));
204 }
205
206 let (valid, errors) = match graphql_parser::parse_query::<String>(&req.query) {
208 Ok(_) => (true, vec![]),
209 Err(e) => (false, vec![e.to_string()]),
210 };
211
212 let response = ValidateResponse { valid, errors };
213
214 Ok(Json(ApiResponse {
215 status: "success".to_string(),
216 data: response,
217 }))
218}
219
220pub async fn stats_handler<A: DatabaseAdapter>(
234 State(state): State<AppState<A>>,
235) -> Result<Json<ApiResponse<StatsResponse>>, ApiError> {
236 let total_queries = state.metrics.queries_total.load(std::sync::atomic::Ordering::Relaxed);
238 let successful_queries =
239 state.metrics.queries_success.load(std::sync::atomic::Ordering::Relaxed);
240 let failed_queries = state.metrics.queries_error.load(std::sync::atomic::Ordering::Relaxed);
241 let total_duration_us =
242 state.metrics.queries_duration_us.load(std::sync::atomic::Ordering::Relaxed);
243
244 #[allow(clippy::cast_precision_loss)]
246 let average_latency_ms = if total_queries > 0 {
248 (total_duration_us as f64 / total_queries as f64) / 1000.0
249 } else {
250 0.0
251 };
252
253 #[allow(clippy::cast_possible_truncation)]
254 let response = StatsResponse {
257 total_queries: total_queries as usize,
258 successful_queries: successful_queries as usize,
259 failed_queries: failed_queries as usize,
260 average_latency_ms,
261 };
262
263 Ok(Json(ApiResponse {
264 status: "success".to_string(),
265 data: response,
266 }))
267}
268
269pub(crate) fn generate_warnings(complexity: &ComplexityInfo) -> Vec<String> {
273 let mut warnings = vec![];
274
275 if complexity.depth > 10 {
276 warnings.push(format!(
277 "Query nesting depth is {} (threshold: 10). Consider using aliases or fragments.",
278 complexity.depth
279 ));
280 }
281
282 if complexity.complexity > 100 {
283 warnings.push(format!(
284 "Query complexity score is {} (threshold: 100). This may take longer to execute.",
285 complexity.complexity
286 ));
287 }
288
289 if complexity.alias_count > DEFAULT_MAX_ALIASES {
290 warnings.push(format!(
291 "Query has {} aliases (threshold: {DEFAULT_MAX_ALIASES}). High alias counts may indicate amplification.",
292 complexity.alias_count
293 ));
294 }
295
296 warnings
297}
298
299pub(crate) const fn estimate_cost(complexity: &ComplexityInfo) -> usize {
301 let base_cost = 50;
302 let depth_cost = complexity.depth.saturating_mul(10);
303 let complexity_cost = complexity.complexity.saturating_mul(5);
304
305 base_cost + depth_cost + complexity_cost
306}
307
308pub(crate) fn is_db_explain_enabled(
310 debug_config: Option<&fraiseql_core::schema::DebugConfig>,
311) -> bool {
312 debug_config.is_some_and(|c| c.enabled && c.database_explain)
313}