Skip to main content

fraiseql_core/runtime/executor/
explain.rs

1//! EXPLAIN ANALYZE execution for admin diagnostics.
2
3use 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    /// Run `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)` for a named query.
14    ///
15    /// Looks up `query_name` in the compiled schema, builds a parameterized
16    /// WHERE clause from `variables`, and delegates to
17    /// [`DatabaseAdapter::explain_where_query`].  The result includes the
18    /// generated SQL and the raw PostgreSQL EXPLAIN output.
19    ///
20    /// # Arguments
21    ///
22    /// * `query_name` - Name of a regular query in the schema (e.g., `"users"`)
23    /// * `variables` - JSON object whose keys map to equality WHERE conditions
24    /// * `limit` - Optional LIMIT to pass to the query
25    /// * `offset` - Optional OFFSET to pass to the query
26    ///
27    /// # Errors
28    ///
29    /// * `FraiseQLError::Validation` — unknown query name or mutation given
30    /// * `FraiseQLError::Unsupported` — database adapter does not support EXPLAIN ANALYZE
31    /// * `FraiseQLError::Database` — EXPLAIN execution failed
32    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        // Reject mutations up front — EXPLAIN ANALYZE only makes sense for queries.
40        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        // Look up the query definition by name.
51        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        // Get the view name.
68        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        // Build a simple equality WHERE clause from the variables object.
75        let where_clause = build_where_from_variables(variables);
76
77        // Collect parameter values for display in the response.
78        let parameters = collect_parameter_values(variables);
79
80        // Build a human-readable representation of the generated SQL.
81        let generated_sql = build_display_sql(sql_source, variables, limit, offset);
82
83        // Delegate EXPLAIN ANALYZE to the database adapter.
84        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
99/// Convert a JSON variables object into a `WhereClause` using `Eq` operators.
100///
101/// Each key-value pair becomes a `WhereClause::Field { path: [key], operator: Eq, value }`.
102/// Multiple pairs are combined with `WhereClause::And`.
103fn 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
124/// Extract parameter values from a variables object in insertion order.
125fn 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
132/// Build a display representation of the SQL passed to EXPLAIN ANALYZE.
133fn 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; // value shown via parameters field
159    }
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)] // Reason: test code, panics are acceptable
172
173    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    // Minimal mock adapter for unit tests — no database required.
189    struct MockAdapter;
190
191    // Reason: DatabaseAdapter is defined with #[async_trait]; all implementations must match
192    // its transformed method signatures to satisfy the trait contract
193    // async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
194    #[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        // MockAdapter uses the default Unsupported implementation.
295        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}