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;
10use serde::{Deserialize, Serialize};
11
12use crate::routes::{
13    api::types::{ApiError, ApiResponse},
14    graphql::AppState,
15};
16
17/// Request to explain a query.
18#[derive(Debug, Deserialize)]
19pub struct ExplainRequest {
20    /// GraphQL query string to analyze
21    pub query: String,
22}
23
24/// Response from explain endpoint.
25#[derive(Debug, Serialize)]
26pub struct ExplainResponse {
27    /// Original query that was analyzed
28    pub query:          String,
29    /// Generated SQL equivalent (if available)
30    pub sql:            Option<String>,
31    /// Complexity metrics for the query
32    pub complexity:     ComplexityInfo,
33    /// Warning messages for potential issues
34    pub warnings:       Vec<String>,
35    /// Estimated cost to execute the query
36    pub estimated_cost: usize,
37}
38
39/// Complexity information for a query.
40#[derive(Debug, Serialize, Clone, Copy)]
41pub struct ComplexityInfo {
42    /// Maximum nesting depth of the query
43    pub depth:       usize,
44    /// Total number of fields requested
45    pub field_count: usize,
46    /// Combined complexity score (depth × field_count)
47    pub score:       usize,
48}
49
50/// Request to validate a query.
51#[derive(Debug, Deserialize)]
52pub struct ValidateRequest {
53    /// GraphQL query string to validate
54    pub query: String,
55}
56
57/// Response from validate endpoint.
58#[derive(Debug, Serialize)]
59pub struct ValidateResponse {
60    /// Whether the query is syntactically valid
61    pub valid:  bool,
62    /// List of validation errors (if any)
63    pub errors: Vec<String>,
64}
65
66/// Response from stats endpoint.
67#[derive(Debug, Serialize)]
68pub struct StatsResponse {
69    /// Total number of queries executed
70    pub total_queries:      usize,
71    /// Number of successful query executions
72    pub successful_queries: usize,
73    /// Number of failed query executions
74    pub failed_queries:     usize,
75    /// Average latency in milliseconds
76    pub average_latency_ms: f64,
77}
78
79/// Explain query execution plan and complexity.
80///
81/// Analyzes a GraphQL query and returns:
82/// - SQL equivalent
83/// - Complexity metrics (depth, field count, score)
84/// - Warnings for potential performance issues
85/// - Estimated cost to execute
86///
87/// Phase 6.4: Query explanation with SQL generation and complexity metrics
88pub async fn explain_handler<A: DatabaseAdapter>(
89    State(_state): State<AppState<A>>,
90    Json(req): Json<ExplainRequest>,
91) -> Result<Json<ApiResponse<ExplainResponse>>, ApiError> {
92    // Validate query is not empty
93    if req.query.trim().is_empty() {
94        return Err(ApiError::validation_error("Query cannot be empty"));
95    }
96
97    // Calculate complexity metrics
98    let complexity = calculate_complexity(&req.query);
99
100    // Generate warnings for high complexity
101    let warnings = generate_warnings(&complexity);
102
103    // Generate mock SQL (in real implementation, would use QueryPlanner)
104    let sql = generate_mock_sql(&req.query);
105
106    let response = ExplainResponse {
107        query: req.query,
108        sql,
109        complexity,
110        warnings,
111        estimated_cost: estimate_cost(&complexity),
112    };
113
114    Ok(Json(ApiResponse {
115        status: "success".to_string(),
116        data:   response,
117    }))
118}
119
120/// Validate GraphQL query syntax.
121///
122/// Performs basic syntax validation on a GraphQL query.
123/// Returns a list of any errors found.
124pub async fn validate_handler<A: DatabaseAdapter>(
125    State(_state): State<AppState<A>>,
126    Json(req): Json<ValidateRequest>,
127) -> Result<Json<ApiResponse<ValidateResponse>>, ApiError> {
128    if req.query.trim().is_empty() {
129        return Ok(Json(ApiResponse {
130            status: "success".to_string(),
131            data:   ValidateResponse {
132                valid:  false,
133                errors: vec!["Query cannot be empty".to_string()],
134            },
135        }));
136    }
137
138    // Basic syntax check
139    let errors = validate_query_syntax(&req.query);
140    let valid = errors.is_empty();
141
142    let response = ValidateResponse { valid, errors };
143
144    Ok(Json(ApiResponse {
145        status: "success".to_string(),
146        data:   response,
147    }))
148}
149
150/// Get query statistics.
151///
152/// Returns aggregated statistics about query execution performance.
153/// Currently returns placeholder data; in production would be populated
154/// from metrics collection during query execution.
155/// Get query execution statistics.
156///
157/// Returns aggregated metrics from query executions including:
158/// - Total queries executed
159/// - Successful vs failed counts
160/// - Average latency across all executions
161///
162/// Phase 6.3: Query statistics aggregation
163pub async fn stats_handler<A: DatabaseAdapter>(
164    State(state): State<AppState<A>>,
165) -> Result<Json<ApiResponse<StatsResponse>>, ApiError> {
166    // Get metrics from the metrics collector using atomic operations
167    let total_queries = state.metrics.queries_total.load(std::sync::atomic::Ordering::Relaxed);
168    let successful_queries =
169        state.metrics.queries_success.load(std::sync::atomic::Ordering::Relaxed);
170    let failed_queries = state.metrics.queries_error.load(std::sync::atomic::Ordering::Relaxed);
171    let total_duration_us =
172        state.metrics.queries_duration_us.load(std::sync::atomic::Ordering::Relaxed);
173
174    // Calculate average latency in milliseconds
175    let average_latency_ms = if total_queries > 0 {
176        (total_duration_us as f64 / total_queries as f64) / 1000.0
177    } else {
178        0.0
179    };
180
181    let response = StatsResponse {
182        total_queries: total_queries as usize,
183        successful_queries: successful_queries as usize,
184        failed_queries: failed_queries as usize,
185        average_latency_ms,
186    };
187
188    Ok(Json(ApiResponse {
189        status: "success".to_string(),
190        data:   response,
191    }))
192}
193
194// Helper functions
195
196/// Calculate complexity metrics for a GraphQL query.
197fn calculate_complexity(query: &str) -> ComplexityInfo {
198    let depth = calculate_depth(query);
199    let field_count = count_fields(query);
200    let score = depth.saturating_mul(field_count);
201
202    ComplexityInfo {
203        depth,
204        field_count,
205        score,
206    }
207}
208
209/// Calculate maximum nesting depth of braces in a query.
210fn calculate_depth(query: &str) -> usize {
211    let mut max_depth = 0;
212    let mut current_depth = 0;
213
214    for ch in query.chars() {
215        match ch {
216            '{' => {
217                current_depth += 1;
218                max_depth = max_depth.max(current_depth);
219            },
220            '}' => {
221                if current_depth > 0 {
222                    current_depth -= 1;
223                }
224            },
225            _ => {},
226        }
227    }
228
229    max_depth
230}
231
232/// Count approximate number of fields in a GraphQL query.
233/// This is a simple heuristic that counts commas and newlines within braces.
234fn count_fields(query: &str) -> usize {
235    let mut count = 1; // Start with at least one field
236    let mut in_braces = 0;
237
238    for ch in query.chars() {
239        match ch {
240            '{' => in_braces += 1,
241            '}' => {
242                if in_braces > 0 {
243                    in_braces -= 1;
244                }
245            },
246            ',' => {
247                if in_braces > 0 {
248                    count += 1;
249                }
250            },
251            '\n' if in_braces > 0 => {
252                // Rough approximation: each line in field list is a field
253                if !query.contains(',') {
254                    count += 1;
255                }
256            },
257            _ => {},
258        }
259    }
260
261    count.max(1)
262}
263
264/// Generate warnings based on complexity metrics.
265fn generate_warnings(complexity: &ComplexityInfo) -> Vec<String> {
266    let mut warnings = vec![];
267
268    // Warn about deep nesting
269    if complexity.depth > 10 {
270        warnings.push(format!(
271            "Query nesting depth is {} (threshold: 10). Consider using aliases or fragments.",
272            complexity.depth
273        ));
274    }
275
276    // Warn about high complexity score
277    if complexity.score > 500 {
278        warnings.push(format!(
279            "Query complexity score is {} (threshold: 500). This may take longer to execute.",
280            complexity.score
281        ));
282    }
283
284    // Warn about many fields
285    if complexity.field_count > 50 {
286        warnings.push(format!(
287            "Query requests {} fields (threshold: 50). Consider requesting only necessary fields.",
288            complexity.field_count
289        ));
290    }
291
292    warnings
293}
294
295/// Estimate execution cost based on complexity.
296fn estimate_cost(complexity: &ComplexityInfo) -> usize {
297    // Simple cost model: base cost + scaling factor
298    let base_cost = 50;
299    let depth_cost = complexity.depth.saturating_mul(10);
300    let field_cost = complexity.field_count.saturating_mul(5);
301
302    base_cost + depth_cost + field_cost
303}
304
305/// Generate mock SQL from a GraphQL query.
306/// In a real implementation, this would use fraiseql-core's QueryPlanner.
307fn generate_mock_sql(_query: &str) -> Option<String> {
308    // Placeholder: In production, would call:
309    // let planner = fraiseql_core::runtime::planner::QueryPlanner::new(true);
310    // let plan = planner.plan(&parsed)?;
311    // Some(plan.sql)
312
313    Some("SELECT * FROM generated_view".to_string())
314}
315
316/// Validate GraphQL query syntax.
317/// Returns list of syntax errors found.
318fn validate_query_syntax(query: &str) -> Vec<String> {
319    let mut errors = vec![];
320
321    // Check for basic structure
322    if !query.contains('{') || !query.contains('}') {
323        errors.push("Query must contain opening and closing braces".to_string());
324    }
325
326    // Check brace matching
327    let open_braces = query.matches('{').count();
328    let close_braces = query.matches('}').count();
329    if open_braces != close_braces {
330        errors
331            .push(format!("Mismatched braces: {} opening, {} closing", open_braces, close_braces));
332    }
333
334    // Check for at least query/mutation/subscription keyword
335    let has_operation =
336        query.contains("query") || query.contains("mutation") || query.contains("subscription");
337
338    if !has_operation {
339        errors.push("Query must contain query, mutation, or subscription operation".to_string());
340    }
341
342    errors
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn test_calculate_depth_simple() {
351        let depth = calculate_depth("query { users { id } }");
352        assert_eq!(depth, 2);
353    }
354
355    #[test]
356    fn test_calculate_depth_nested() {
357        let depth = calculate_depth("query { users { posts { comments { text } } } }");
358        assert_eq!(depth, 4);
359    }
360
361    #[test]
362    fn test_count_fields_single() {
363        let count = count_fields("query { users { id } }");
364        assert!(count >= 1);
365    }
366
367    #[test]
368    fn test_generate_warnings_deep() {
369        let complexity = ComplexityInfo {
370            depth:       15,
371            field_count: 5,
372            score:       75,
373        };
374        let warnings = generate_warnings(&complexity);
375        assert!(!warnings.is_empty());
376        assert!(warnings[0].contains("depth"));
377    }
378
379    #[test]
380    fn test_generate_warnings_high_score() {
381        let complexity = ComplexityInfo {
382            depth:       3,
383            field_count: 200,
384            score:       600,
385        };
386        let warnings = generate_warnings(&complexity);
387        assert!(!warnings.is_empty());
388        assert!(warnings.iter().any(|w| w.contains("complexity")));
389    }
390
391    #[test]
392    fn test_estimate_cost() {
393        let complexity = ComplexityInfo {
394            depth:       2,
395            field_count: 3,
396            score:       6,
397        };
398        let cost = estimate_cost(&complexity);
399        assert!(cost > 0);
400    }
401
402    #[test]
403    fn test_validate_empty_query() {
404        let errors = validate_query_syntax("");
405        assert!(!errors.is_empty());
406    }
407
408    #[test]
409    fn test_validate_mismatched_braces() {
410        let errors = validate_query_syntax("query { users { id }");
411        assert!(!errors.is_empty());
412        assert!(errors[0].contains("Mismatched"));
413    }
414
415    #[test]
416    fn test_validate_valid_query() {
417        let errors = validate_query_syntax("query { users { id } }");
418        assert!(errors.is_empty());
419    }
420
421    #[test]
422    fn test_stats_response_structure() {
423        // Phase 6.3: Query statistics response structure
424        let response = StatsResponse {
425            total_queries:      100,
426            successful_queries: 95,
427            failed_queries:     5,
428            average_latency_ms: 42.5,
429        };
430
431        assert_eq!(response.total_queries, 100);
432        assert_eq!(response.successful_queries, 95);
433        assert_eq!(response.failed_queries, 5);
434        assert!(response.average_latency_ms > 0.0);
435    }
436
437    #[test]
438    fn test_explain_response_structure() {
439        // Phase 6.4: Query explanation response structure
440        let response = ExplainResponse {
441            query:          "query { users { id } }".to_string(),
442            sql:            Some("SELECT id FROM users".to_string()),
443            complexity:     ComplexityInfo {
444                depth:       2,
445                field_count: 1,
446                score:       2,
447            },
448            warnings:       vec![],
449            estimated_cost: 50,
450        };
451
452        assert!(!response.query.is_empty());
453        assert!(response.sql.is_some());
454        assert_eq!(response.complexity.depth, 2);
455        assert_eq!(response.estimated_cost, 50);
456    }
457
458    #[test]
459    fn test_complexity_info_score_calculation() {
460        // Phase 6.4: Complexity score is calculated correctly
461        let complexity = ComplexityInfo {
462            depth:       3,
463            field_count: 4,
464            score:       12,
465        };
466
467        assert_eq!(complexity.score, 3 * 4);
468    }
469
470    #[test]
471    fn test_validate_request_structure() {
472        let request = ValidateRequest {
473            query: "query { users { id } }".to_string(),
474        };
475
476        assert!(!request.query.is_empty());
477    }
478
479    #[test]
480    fn test_explain_request_structure() {
481        let request = ExplainRequest {
482            query: "query { users { id } }".to_string(),
483        };
484
485        assert!(!request.query.is_empty());
486    }
487}