fraiseql_core/runtime/executor/
explain.rs1use std::fmt::Write as _;
4
5use super::Executor;
6use crate::{
7 db::{WhereClause, WhereOperator, traits::DatabaseAdapter},
8 error::{FraiseQLError, Result},
9 runtime::explain::ExplainResult,
10};
11
12impl<A: DatabaseAdapter> Executor<A> {
13 pub async fn explain(
33 &self,
34 query_name: &str,
35 variables: Option<&serde_json::Value>,
36 limit: Option<u32>,
37 offset: Option<u32>,
38 ) -> Result<ExplainResult> {
39 if self.schema.mutations.iter().any(|m| m.name == query_name) {
41 return Err(FraiseQLError::Validation {
42 message: format!(
43 "EXPLAIN ANALYZE is not supported for mutations. \
44 '{query_name}' is a mutation; only regular queries are supported."
45 ),
46 path: None,
47 });
48 }
49
50 let query_def =
52 self.schema.queries.iter().find(|q| q.name == query_name).ok_or_else(|| {
53 let display_names: Vec<String> =
54 self.schema.queries.iter().map(|q| self.schema.display_name(&q.name)).collect();
55 let candidate_refs: Vec<&str> = display_names.iter().map(String::as_str).collect();
56 let suggestion = crate::runtime::suggest_similar(query_name, &candidate_refs);
57 let message = match suggestion.as_slice() {
58 [s] => format!("Query '{query_name}' not found in schema. Did you mean '{s}'?"),
59 _ => format!("Query '{query_name}' not found in schema"),
60 };
61 FraiseQLError::Validation {
62 message,
63 path: None,
64 }
65 })?;
66
67 let sql_source =
69 query_def.sql_source.as_ref().ok_or_else(|| FraiseQLError::Validation {
70 message: format!("Query '{query_name}' has no SQL source"),
71 path: None,
72 })?;
73
74 let where_clause = build_where_from_variables(variables);
76
77 let parameters = collect_parameter_values(variables);
79
80 let generated_sql = build_display_sql(sql_source, variables, limit, offset);
82
83 let explain_output = self
85 .adapter
86 .explain_where_query(sql_source, where_clause.as_ref(), limit, offset)
87 .await?;
88
89 Ok(ExplainResult {
90 query_name: query_name.to_string(),
91 sql_source: sql_source.clone(),
92 generated_sql,
93 parameters,
94 explain_output,
95 })
96 }
97}
98
99fn build_where_from_variables(variables: Option<&serde_json::Value>) -> Option<WhereClause> {
104 let map = variables?.as_object()?;
105 if map.is_empty() {
106 return None;
107 }
108 let mut conditions: Vec<WhereClause> = map
109 .iter()
110 .map(|(k, v)| WhereClause::Field {
111 path: vec![k.clone()],
112 operator: WhereOperator::Eq,
113 value: v.clone(),
114 })
115 .collect();
116
117 if conditions.len() == 1 {
118 conditions.pop()
119 } else {
120 Some(WhereClause::And(conditions))
121 }
122}
123
124fn collect_parameter_values(variables: Option<&serde_json::Value>) -> Vec<serde_json::Value> {
126 variables
127 .and_then(|v| v.as_object())
128 .map(|map| map.values().cloned().collect())
129 .unwrap_or_default()
130}
131
132fn build_display_sql(
134 sql_source: &str,
135 variables: Option<&serde_json::Value>,
136 limit: Option<u32>,
137 offset: Option<u32>,
138) -> String {
139 let mut sql =
140 format!("EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT data FROM \"{sql_source}\"");
141
142 if let Some(map) = variables.and_then(|v| v.as_object()) {
143 if !map.is_empty() {
144 let conditions: Vec<String> = map
145 .keys()
146 .enumerate()
147 .map(|(i, k)| format!("data->>'{}' = ${}", k, i + 1))
148 .collect();
149 sql.push_str(" WHERE ");
150 sql.push_str(&conditions.join(" AND "));
151 }
152 }
153
154 let param_offset = variables.and_then(|v| v.as_object()).map_or(0, |m| m.len());
155
156 if let Some(lim) = limit {
157 let _ = write!(sql, " LIMIT ${}", param_offset + 1);
158 let _ = lim; }
160 if let Some(off) = offset {
161 let limit_added = usize::from(limit.is_some());
162 let _ = write!(sql, " OFFSET ${}", param_offset + limit_added + 1);
163 let _ = off;
164 }
165
166 sql
167}
168
169#[cfg(test)]
170mod tests {
171 #![allow(clippy::unwrap_used)] use std::sync::Arc;
174
175 use async_trait::async_trait;
176 use serde_json::json;
177
178 use crate::{
179 db::{
180 DatabaseType, PoolMetrics, WhereClause,
181 types::{JsonbValue, OrderByClause},
182 },
183 error::{FraiseQLError, Result},
184 runtime::Executor,
185 schema::{CompiledSchema, MutationDefinition, QueryDefinition},
186 };
187
188 struct MockAdapter;
190
191 #[async_trait]
195 impl crate::db::traits::DatabaseAdapter for MockAdapter {
196 async fn execute_where_query(
197 &self,
198 _view: &str,
199 _where_clause: Option<&WhereClause>,
200 _limit: Option<u32>,
201 _offset: Option<u32>,
202 _order_by: Option<&[OrderByClause]>,
203 ) -> Result<Vec<JsonbValue>> {
204 Ok(vec![])
205 }
206
207 async fn execute_with_projection(
208 &self,
209 _view: &str,
210 _projection: Option<&crate::schema::SqlProjectionHint>,
211 _where_clause: Option<&WhereClause>,
212 _limit: Option<u32>,
213 _offset: Option<u32>,
214 _order_by: Option<&[OrderByClause]>,
215 ) -> Result<Vec<JsonbValue>> {
216 Ok(vec![])
217 }
218
219 fn database_type(&self) -> DatabaseType {
220 DatabaseType::SQLite
221 }
222
223 async fn health_check(&self) -> Result<()> {
224 Ok(())
225 }
226
227 fn pool_metrics(&self) -> PoolMetrics {
228 PoolMetrics {
229 total_connections: 1,
230 idle_connections: 1,
231 active_connections: 0,
232 waiting_requests: 0,
233 }
234 }
235
236 async fn execute_raw_query(
237 &self,
238 _sql: &str,
239 ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
240 Ok(vec![])
241 }
242
243 async fn execute_parameterized_aggregate(
244 &self,
245 _sql: &str,
246 _params: &[serde_json::Value],
247 ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
248 Ok(vec![])
249 }
250 }
251
252 fn make_schema_with_query(name: &str, sql_source: &str) -> CompiledSchema {
253 let mut schema = CompiledSchema::default();
254 let mut qd = QueryDefinition::new(name, "SomeType");
255 qd.sql_source = Some(sql_source.to_string());
256 schema.queries.push(qd);
257 schema
258 }
259
260 fn make_schema_with_mutation(name: &str) -> CompiledSchema {
261 let mut schema = CompiledSchema::default();
262 let mut md = MutationDefinition::new(name, "MutationResponse");
263 md.sql_source = Some(format!("fn_{name}"));
264 schema.mutations.push(md);
265 schema
266 }
267
268 #[tokio::test]
269 async fn test_explain_unknown_query_returns_error() {
270 let schema = make_schema_with_query("users", "v_user");
271 let executor = Executor::new(schema, Arc::new(MockAdapter));
272
273 let err = executor.explain("nonexistent", None, None, None).await.unwrap_err();
274 assert!(
275 matches!(&err, FraiseQLError::Validation { message, .. } if message.contains("nonexistent")),
276 "expected Validation error mentioning the query name, got: {err:?}"
277 );
278 }
279
280 #[tokio::test]
281 async fn test_explain_mutation_returns_error() {
282 let schema = make_schema_with_mutation("createUser");
283 let executor = Executor::new(schema, Arc::new(MockAdapter));
284
285 let err = executor.explain("createUser", None, None, None).await.unwrap_err();
286 assert!(
287 matches!(&err, FraiseQLError::Validation { message, .. } if message.contains("mutation")),
288 "expected Validation error mentioning mutation, got: {err:?}"
289 );
290 }
291
292 #[tokio::test]
293 async fn test_explain_unsupported_adapter_returns_error() {
294 let schema = make_schema_with_query("users", "v_user");
296 let executor = Executor::new(schema, Arc::new(MockAdapter));
297
298 let err = executor
299 .explain("users", Some(&json!({"status": "active"})), Some(10), None)
300 .await
301 .unwrap_err();
302 assert!(
303 matches!(&err, FraiseQLError::Unsupported { .. }),
304 "expected Unsupported error from mock adapter, got: {err:?}"
305 );
306 }
307
308 #[test]
309 fn test_build_display_sql_no_clause() {
310 let sql = super::build_display_sql("v_user", None, None, None);
311 assert_eq!(sql, "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT data FROM \"v_user\"");
312 }
313
314 #[test]
315 fn test_build_display_sql_with_limit_offset() {
316 let vars = json!({"status": "active"});
317 let sql = super::build_display_sql("v_user", Some(&vars), Some(10), Some(20));
318 assert!(sql.contains("LIMIT $2"), "should contain LIMIT $2, got: {sql}");
319 assert!(sql.contains("OFFSET $3"), "should contain OFFSET $3, got: {sql}");
320 }
321}