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
269fn 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
299const 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
308fn is_db_explain_enabled(debug_config: Option<&fraiseql_core::schema::DebugConfig>) -> bool {
310 debug_config.is_some_and(|c| c.enabled && c.database_explain)
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn test_generate_warnings_deep() {
319 let complexity = ComplexityInfo {
320 depth: 15,
321 complexity: 10,
322 alias_count: 0,
323 };
324 let warnings = generate_warnings(&complexity);
325 assert!(!warnings.is_empty());
326 assert!(warnings[0].contains("depth"));
327 }
328
329 #[test]
330 fn test_generate_warnings_high_complexity() {
331 let complexity = ComplexityInfo {
332 depth: 3,
333 complexity: 200,
334 alias_count: 0,
335 };
336 let warnings = generate_warnings(&complexity);
337 assert!(!warnings.is_empty());
338 assert!(warnings.iter().any(|w| w.contains("complexity")));
339 }
340
341 #[test]
342 fn test_generate_warnings_high_alias_count() {
343 let complexity = ComplexityInfo {
344 depth: 2,
345 complexity: 5,
346 alias_count: 35,
347 };
348 let warnings = generate_warnings(&complexity);
349 assert!(warnings.iter().any(|w| w.contains("alias")));
350 }
351
352 #[test]
353 fn test_estimate_cost() {
354 let complexity = ComplexityInfo {
355 depth: 2,
356 complexity: 3,
357 alias_count: 0,
358 };
359 let cost = estimate_cost(&complexity);
360 assert!(cost > 0);
361 }
362
363 #[test]
364 fn test_stats_response_structure() {
365 let response = StatsResponse {
366 total_queries: 100,
367 successful_queries: 95,
368 failed_queries: 5,
369 average_latency_ms: 42.5,
370 };
371 assert_eq!(response.total_queries, 100);
372 assert_eq!(response.successful_queries, 95);
373 assert_eq!(response.failed_queries, 5);
374 assert!(response.average_latency_ms > 0.0);
375 }
376
377 #[test]
378 fn test_explain_response_structure() {
379 let response = ExplainResponse {
380 query: "query { users { id } }".to_string(),
381 sql: Some("SELECT id FROM users".to_string()),
382 complexity: ComplexityInfo {
383 depth: 2,
384 complexity: 2,
385 alias_count: 0,
386 },
387 warnings: vec![],
388 estimated_cost: 50,
389 views_accessed: vec!["v_user".to_string()],
390 query_type: "regular".to_string(),
391 database_plan: None,
392 };
393
394 assert!(!response.query.is_empty());
395 assert_eq!(response.sql.as_deref(), Some("SELECT id FROM users"));
396 assert_eq!(response.complexity.depth, 2);
397 assert_eq!(response.estimated_cost, 50);
398 }
399
400 #[test]
401 fn test_validate_request_structure() {
402 let request = ValidateRequest {
403 query: "query { users { id } }".to_string(),
404 };
405 assert!(!request.query.is_empty());
406 }
407
408 #[test]
409 fn test_explain_request_structure() {
410 let request = ExplainRequest {
411 query: "query { users { id } }".to_string(),
412 variables: None,
413 };
414 assert!(!request.query.is_empty());
415 }
416
417 #[test]
418 fn test_debug_disabled_no_db_explain() {
419 use fraiseql_core::schema::DebugConfig;
420
421 assert!(!is_db_explain_enabled(None));
422
423 let config = DebugConfig {
424 enabled: true,
425 database_explain: false,
426 ..Default::default()
427 };
428 assert!(!is_db_explain_enabled(Some(&config)));
429 }
430
431 #[test]
432 fn test_debug_enabled_db_explain() {
433 use fraiseql_core::schema::DebugConfig;
434
435 let config = DebugConfig {
436 enabled: true,
437 database_explain: true,
438 ..Default::default()
439 };
440 assert!(is_db_explain_enabled(Some(&config)));
441 }
442
443 #[test]
444 fn test_debug_master_switch_required() {
445 use fraiseql_core::schema::DebugConfig;
446
447 let config = DebugConfig {
448 enabled: false,
449 database_explain: true,
450 ..Default::default()
451 };
452 assert!(!is_db_explain_enabled(Some(&config)));
453 }
454}