Skip to main content

fraiseql_server/routes/api/
query.rs

1//! Query intelligence API endpoints.
2//!
3//! Provides endpoints for:
4//! - Explaining query execution plans with complexity metrics
5//! - Validating GraphQL query syntax
6//! - Retrieving query statistics and performance data
7
8use 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/// Request to explain a query.
21#[derive(Debug, Deserialize)]
22pub struct ExplainRequest {
23    /// GraphQL query string to analyze
24    pub query:     String,
25    /// Optional GraphQL variables
26    #[serde(default)]
27    pub variables: Option<serde_json::Value>,
28}
29
30/// Response from explain endpoint.
31#[derive(Debug, Serialize)]
32pub struct ExplainResponse {
33    /// Original query that was analyzed
34    pub query:          String,
35    /// Generated SQL equivalent (if available)
36    pub sql:            Option<String>,
37    /// Complexity metrics for the query
38    pub complexity:     ComplexityInfo,
39    /// Warning messages for potential issues
40    pub warnings:       Vec<String>,
41    /// Estimated cost to execute the query
42    pub estimated_cost: usize,
43    /// Views/tables that would be accessed
44    pub views_accessed: Vec<String>,
45    /// Query type classification
46    pub query_type:     String,
47    /// Database-level EXPLAIN output (only when `debug.database_explain` is enabled)
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub database_plan:  Option<serde_json::Value>,
50}
51
52/// Complexity information for a query.
53///
54/// All metrics are computed via AST walking (not character scanning), so
55/// operation names, arguments, and directives are never miscounted as fields.
56#[derive(Debug, Serialize, Clone, Copy)]
57pub struct ComplexityInfo {
58    /// Maximum selection-set nesting depth.
59    pub depth:       usize,
60    /// Complexity score (accounts for pagination multipliers on list fields).
61    pub complexity:  usize,
62    /// Number of aliased fields (alias amplification indicator).
63    pub alias_count: usize,
64}
65
66/// Request to validate a query.
67#[derive(Debug, Deserialize)]
68pub struct ValidateRequest {
69    /// GraphQL query string to validate
70    pub query: String,
71}
72
73/// Response from validate endpoint.
74#[derive(Debug, Serialize)]
75pub struct ValidateResponse {
76    /// Whether the query is syntactically valid
77    pub valid:  bool,
78    /// List of validation errors (if any)
79    pub errors: Vec<String>,
80}
81
82/// Response from stats endpoint.
83#[derive(Debug, Serialize)]
84pub struct StatsResponse {
85    /// Total number of queries executed
86    pub total_queries:      usize,
87    /// Number of successful query executions
88    pub successful_queries: usize,
89    /// Number of failed query executions
90    pub failed_queries:     usize,
91    /// Average latency in milliseconds
92    pub average_latency_ms: f64,
93}
94
95/// Explain query execution plan and complexity.
96///
97/// Analyzes a GraphQL query using AST-based validation and returns:
98/// - SQL equivalent
99/// - Complexity metrics (depth, complexity score, alias count)
100/// - Warnings for potential performance issues
101/// - Estimated cost to execute
102///
103/// # Errors
104///
105/// Returns `ApiError` with a validation error if the query string is empty.
106pub 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    // Validate query is not empty
111    if req.query.trim().is_empty() {
112        return Err(ApiError::validation_error("Query cannot be empty"));
113    }
114
115    // Compute AST-based complexity metrics.
116    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    // Generate warnings for high complexity
128    let warnings = generate_warnings(&complexity);
129
130    // Use the real QueryPlanner via Executor::plan_query
131    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                // Optionally run DB-level EXPLAIN when debug.database_explain is enabled
136                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                // Fall back to heuristic cost if the query can't be planned
162                // (e.g. schema mismatch, parse errors that passed basic validation)
163                (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
184/// Validate GraphQL query syntax.
185///
186/// Performs full AST-based validation on a GraphQL query.
187/// Returns a list of any errors found.
188///
189/// # Errors
190///
191/// This handler always succeeds; validation errors are reported inside the response body.
192pub 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    // Full AST parse: reports real syntax errors, not brace-matching heuristics.
207    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
220/// Get query execution statistics.
221///
222/// Returns aggregated metrics from query executions, read from the in-process
223/// atomic counters that the GraphQL handler updates on every request:
224/// - Total queries executed
225/// - Successful vs failed counts
226/// - Average latency in milliseconds (computed from cumulative microseconds)
227///
228/// Counters reset to zero on server restart (they are not persisted).
229///
230/// # Errors
231///
232/// This handler is infallible.
233pub async fn stats_handler<A: DatabaseAdapter>(
234    State(state): State<AppState<A>>,
235) -> Result<Json<ApiResponse<StatsResponse>>, ApiError> {
236    // Get metrics from the metrics collector using atomic operations
237    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    // Calculate average latency in milliseconds
245    #[allow(clippy::cast_precision_loss)]
246    // Reason: precision loss is acceptable for metrics/statistics
247    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    // Reason: AtomicU64 counters fit in usize on 64-bit targets; saturating is acceptable for
255    // display stats
256    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
269// ── Internal helpers ──────────────────────────────────────────────────────────
270
271/// Generate warnings based on AST-based complexity metrics.
272fn 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
299/// Estimate execution cost based on AST-based complexity metrics.
300const 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
308/// Check whether DB-level EXPLAIN is enabled in the debug configuration.
309fn 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}