fraiseql_server/routes/api/query_stats/
mod.rs1use 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#[derive(Debug, Deserialize)]
22pub struct QueryStatsParams {
23 pub limit: Option<u32>,
25}
26
27#[derive(Debug, Serialize)]
29pub struct QueryStatsResponse {
30 pub database_type: String,
32 pub stats_available: bool,
34 pub entries: Vec<QueryStatEntry>,
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub message: Option<String>,
39}
40
41#[derive(Debug, Serialize)]
43pub struct QueryStatsDetailResponse {
44 pub database_type: String,
46 pub entry: QueryStatEntry,
48}
49
50#[derive(Debug, Serialize)]
52pub struct QueryStatsResetResponse {
53 pub message: String,
55}
56
57pub 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
102pub 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
135pub 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)] mod tests;