Skip to main content

fraiseql_server/routes/api/query_stats/
mod.rs

1//! Admin query-stats endpoints.
2//!
3//! Surfaces database-level query performance statistics via:
4//! - `GET /api/v1/admin/query-stats` — top-N queries by execution time
5//! - `GET /api/v1/admin/query-stats/{queryid}` — single query detail
6//! - `POST /api/v1/admin/query-stats/reset` — reset statistics (PG only)
7
8use axum::{
9    Json,
10    extract::{Path, Query, State},
11};
12use fraiseql_core::db::{DatabaseType, QueryStatEntry, traits::DatabaseAdapter};
13use serde::{Deserialize, Serialize};
14
15use crate::routes::{
16    api::types::{ApiError, ApiResponse},
17    graphql::AppState,
18};
19
20/// Query parameters for the stats listing endpoint.
21#[derive(Debug, Deserialize)]
22pub struct QueryStatsParams {
23    /// Maximum number of entries to return (default 20, clamped to 1..=100).
24    pub limit: Option<u32>,
25}
26
27/// Response payload for the query-stats listing endpoint.
28#[derive(Debug, Serialize)]
29pub struct QueryStatsResponse {
30    /// Which database backend produced this data.
31    pub database_type:   String,
32    /// Whether this backend supports query stats at all.
33    pub stats_available: bool,
34    /// The query statistics entries.
35    pub entries:         Vec<QueryStatEntry>,
36    /// Optional informational message (e.g., extension not installed).
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub message:         Option<String>,
39}
40
41/// Response payload for a single query detail.
42#[derive(Debug, Serialize)]
43pub struct QueryStatsDetailResponse {
44    /// Which database backend produced this data.
45    pub database_type: String,
46    /// The query statistics entry.
47    pub entry:         QueryStatEntry,
48}
49
50/// Response payload for the reset endpoint.
51#[derive(Debug, Serialize)]
52pub struct QueryStatsResetResponse {
53    /// Confirmation message.
54    pub message: String,
55}
56
57/// `GET /api/v1/admin/query-stats`
58///
59/// Returns top-N queries by total execution time.
60///
61/// # Errors
62///
63/// Returns `ApiError` with `INTERNAL_ERROR` if the database query fails.
64pub async fn query_stats_handler<A: DatabaseAdapter + 'static>(
65    State(state): State<AppState<A>>,
66    Query(params): Query<QueryStatsParams>,
67) -> Result<Json<ApiResponse<QueryStatsResponse>>, ApiError> {
68    let limit = params.limit.unwrap_or(20).clamp(1, 100);
69    let executor = state.executor();
70    let adapter = executor.adapter();
71    let db_type = adapter.database_type();
72
73    let stats_available = !matches!(db_type, DatabaseType::SQLite);
74
75    let entries = adapter
76        .query_stats(limit)
77        .await
78        .map_err(|e| ApiError::internal_error(format!("Failed to fetch query stats: {e}")))?;
79
80    let message = if !stats_available {
81        Some("Query stats are not supported by SQLite".to_string())
82    } else if entries.is_empty() {
83        Some(
84            "No query stats recorded yet (extension may not be installed or no queries executed)"
85                .to_string(),
86        )
87    } else {
88        None
89    };
90
91    Ok(Json(ApiResponse {
92        status: "success".to_string(),
93        data:   QueryStatsResponse {
94            database_type: db_type.to_string(),
95            stats_available,
96            entries,
97            message,
98        },
99    }))
100}
101
102/// `GET /api/v1/admin/query-stats/{queryid}`
103///
104/// Returns detail for a single query by its ID.
105///
106/// # Errors
107///
108/// Returns `ApiError` with `NOT_FOUND` if the query ID is not found.
109/// Returns `ApiError` with `INTERNAL_ERROR` if the database query fails.
110pub async fn query_stats_detail_handler<A: DatabaseAdapter + 'static>(
111    State(state): State<AppState<A>>,
112    Path(queryid): Path<String>,
113) -> Result<Json<ApiResponse<QueryStatsDetailResponse>>, ApiError> {
114    let executor = state.executor();
115    let adapter = executor.adapter();
116    let db_type = adapter.database_type();
117
118    let entry = adapter
119        .query_stats_by_id(&queryid)
120        .await
121        .map_err(|e| ApiError::internal_error(format!("Failed to fetch query stats: {e}")))?;
122
123    match entry {
124        Some(entry) => Ok(Json(ApiResponse {
125            status: "success".to_string(),
126            data:   QueryStatsDetailResponse {
127                database_type: db_type.to_string(),
128                entry,
129            },
130        })),
131        None => Err(ApiError::not_found(format!("No query stats found for id '{queryid}'"))),
132    }
133}
134
135/// `POST /api/v1/admin/query-stats/reset`
136///
137/// Resets query performance statistics. Only PostgreSQL supports this.
138///
139/// # Errors
140///
141/// Returns 501 if the backend does not support reset.
142/// Returns `ApiError` with `INTERNAL_ERROR` on other failures.
143pub async fn query_stats_reset_handler<A: DatabaseAdapter + 'static>(
144    State(state): State<AppState<A>>,
145) -> Result<Json<ApiResponse<QueryStatsResetResponse>>, ApiError> {
146    let executor = state.executor();
147    let adapter = executor.adapter();
148
149    match adapter.reset_query_stats().await {
150        Ok(()) => Ok(Json(ApiResponse {
151            status: "success".to_string(),
152            data:   QueryStatsResetResponse {
153                message: "Query statistics have been reset".to_string(),
154            },
155        })),
156        Err(fraiseql_error::FraiseQLError::Unsupported { message }) => {
157            Err(ApiError::new(message, "UNSUPPORTED_OPERATION".to_string()))
158        },
159        Err(e) => Err(ApiError::internal_error(format!("Failed to reset query stats: {e}"))),
160    }
161}
162
163#[cfg(test)]
164#[allow(clippy::unwrap_used)] // Reason: test assertions — panics are the intended failure mode
165mod tests;