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.
272pub(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
299/// Estimate execution cost based on AST-based complexity metrics.
300pub(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
308/// Check whether DB-level EXPLAIN is enabled in the debug configuration.
309pub(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}