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 candidates: Vec<&str> =
54                    self.schema.queries.iter().map(|q| q.name.as_str()).collect();
55                let suggestion = crate::runtime::suggest_similar(query_name, &candidates);
56                let message = match suggestion.as_slice() {
57                    [s] => format!("Query '{query_name}' not found in schema. Did you mean '{s}'?"),
58                    _ => format!("Query '{query_name}' not found in schema"),
59                };
60                FraiseQLError::Validation {
61                    message,
62                    path: None,
63                }
64            })?;
65
66        // Get the view name.
67        let sql_source =
68            query_def.sql_source.as_ref().ok_or_else(|| FraiseQLError::Validation {
69                message: format!("Query '{query_name}' has no SQL source"),
70                path:    None,
71            })?;
72
73        // Build a simple equality WHERE clause from the variables object.
74        let where_clause = build_where_from_variables(variables);
75
76        // Collect parameter values for display in the response.
77        let parameters = collect_parameter_values(variables);
78
79        // Build a human-readable representation of the generated SQL.
80        let generated_sql = build_display_sql(sql_source, variables, limit, offset);
81
82        // Delegate EXPLAIN ANALYZE to the database adapter.
83        let explain_output = self
84            .adapter
85            .explain_where_query(sql_source, where_clause.as_ref(), limit, offset)
86            .await?;
87
88        Ok(ExplainResult {
89            query_name: query_name.to_string(),
90            sql_source: sql_source.clone(),
91            generated_sql,
92            parameters,
93            explain_output,
94        })
95    }
96}
97
98/// Convert a JSON variables object into a `WhereClause` using `Eq` operators.
99///
100/// Each key-value pair becomes a `WhereClause::Field { path: [key], operator: Eq, value }`.
101/// Multiple pairs are combined with `WhereClause::And`.
102fn build_where_from_variables(variables: Option<&serde_json::Value>) -> Option<WhereClause> {
103    let map = variables?.as_object()?;
104    if map.is_empty() {
105        return None;
106    }
107    let mut conditions: Vec<WhereClause> = map
108        .iter()
109        .map(|(k, v)| WhereClause::Field {
110            path:     vec![k.clone()],
111            operator: WhereOperator::Eq,
112            value:    v.clone(),
113        })
114        .collect();
115
116    if conditions.len() == 1 {
117        conditions.pop()
118    } else {
119        Some(WhereClause::And(conditions))
120    }
121}
122
123/// Extract parameter values from a variables object in insertion order.
124fn collect_parameter_values(variables: Option<&serde_json::Value>) -> Vec<serde_json::Value> {
125    variables
126        .and_then(|v| v.as_object())
127        .map(|map| map.values().cloned().collect())
128        .unwrap_or_default()
129}
130
131/// Build a display representation of the SQL passed to EXPLAIN ANALYZE.
132fn build_display_sql(
133    sql_source: &str,
134    variables: Option<&serde_json::Value>,
135    limit: Option<u32>,
136    offset: Option<u32>,
137) -> String {
138    let mut sql =
139        format!("EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT data FROM \"{sql_source}\"");
140
141    if let Some(map) = variables.and_then(|v| v.as_object()) {
142        if !map.is_empty() {
143            let conditions: Vec<String> = map
144                .keys()
145                .enumerate()
146                .map(|(i, k)| format!("data->>'{}' = ${}", k, i + 1))
147                .collect();
148            sql.push_str(" WHERE ");
149            sql.push_str(&conditions.join(" AND "));
150        }
151    }
152
153    let param_offset = variables.and_then(|v| v.as_object()).map_or(0, |m| m.len());
154
155    if let Some(lim) = limit {
156        let _ = write!(sql, " LIMIT ${}", param_offset + 1);
157        let _ = lim; // value shown via parameters field
158    }
159    if let Some(off) = offset {
160        let limit_added = usize::from(limit.is_some());
161        let _ = write!(sql, " OFFSET ${}", param_offset + limit_added + 1);
162        let _ = off;
163    }
164
165    sql
166}
167
168#[cfg(test)]
169mod tests {
170    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
171
172    use std::sync::Arc;
173
174    use async_trait::async_trait;
175    use serde_json::json;
176
177    use crate::{
178        db::{
179            DatabaseType, PoolMetrics, WhereClause,
180            types::{JsonbValue, OrderByClause},
181        },
182        error::{FraiseQLError, Result},
183        runtime::Executor,
184        schema::{CompiledSchema, MutationDefinition, QueryDefinition},
185    };
186
187    // Minimal mock adapter for unit tests — no database required.
188    struct MockAdapter;
189
190    // Reason: DatabaseAdapter is defined with #[async_trait]; all implementations must match
191    // its transformed method signatures to satisfy the trait contract
192    // async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
193    #[async_trait]
194    impl crate::db::traits::DatabaseAdapter for MockAdapter {
195        async fn execute_where_query(
196            &self,
197            _view: &str,
198            _where_clause: Option<&WhereClause>,
199            _limit: Option<u32>,
200            _offset: Option<u32>,
201            _order_by: Option<&[OrderByClause]>,
202        ) -> Result<Vec<JsonbValue>> {
203            Ok(vec![])
204        }
205
206        async fn execute_with_projection(
207            &self,
208            _view: &str,
209            _projection: Option<&crate::schema::SqlProjectionHint>,
210            _where_clause: Option<&WhereClause>,
211            _limit: Option<u32>,
212            _offset: Option<u32>,
213            _order_by: Option<&[OrderByClause]>,
214        ) -> Result<Vec<JsonbValue>> {
215            Ok(vec![])
216        }
217
218        fn database_type(&self) -> DatabaseType {
219            DatabaseType::SQLite
220        }
221
222        async fn health_check(&self) -> Result<()> {
223            Ok(())
224        }
225
226        fn pool_metrics(&self) -> PoolMetrics {
227            PoolMetrics {
228                total_connections:  1,
229                idle_connections:   1,
230                active_connections: 0,
231                waiting_requests:   0,
232            }
233        }
234
235        async fn execute_raw_query(
236            &self,
237            _sql: &str,
238        ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
239            Ok(vec![])
240        }
241
242        async fn execute_parameterized_aggregate(
243            &self,
244            _sql: &str,
245            _params: &[serde_json::Value],
246        ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
247            Ok(vec![])
248        }
249    }
250
251    fn make_schema_with_query(name: &str, sql_source: &str) -> CompiledSchema {
252        let mut schema = CompiledSchema::default();
253        let mut qd = QueryDefinition::new(name, "SomeType");
254        qd.sql_source = Some(sql_source.to_string());
255        schema.queries.push(qd);
256        schema
257    }
258
259    fn make_schema_with_mutation(name: &str) -> CompiledSchema {
260        let mut schema = CompiledSchema::default();
261        let mut md = MutationDefinition::new(name, "MutationResponse");
262        md.sql_source = Some(format!("fn_{name}"));
263        schema.mutations.push(md);
264        schema
265    }
266
267    #[tokio::test]
268    async fn test_explain_unknown_query_returns_error() {
269        let schema = make_schema_with_query("users", "v_user");
270        let executor = Executor::new(schema, Arc::new(MockAdapter));
271
272        let err = executor.explain("nonexistent", None, None, None).await.unwrap_err();
273        assert!(
274            matches!(&err, FraiseQLError::Validation { message, .. } if message.contains("nonexistent")),
275            "expected Validation error mentioning the query name, got: {err:?}"
276        );
277    }
278
279    #[tokio::test]
280    async fn test_explain_mutation_returns_error() {
281        let schema = make_schema_with_mutation("createUser");
282        let executor = Executor::new(schema, Arc::new(MockAdapter));
283
284        let err = executor.explain("createUser", None, None, None).await.unwrap_err();
285        assert!(
286            matches!(&err, FraiseQLError::Validation { message, .. } if message.contains("mutation")),
287            "expected Validation error mentioning mutation, got: {err:?}"
288        );
289    }
290
291    #[tokio::test]
292    async fn test_explain_unsupported_adapter_returns_error() {
293        // MockAdapter uses the default Unsupported implementation.
294        let schema = make_schema_with_query("users", "v_user");
295        let executor = Executor::new(schema, Arc::new(MockAdapter));
296
297        let err = executor
298            .explain("users", Some(&json!({"status": "active"})), Some(10), None)
299            .await
300            .unwrap_err();
301        assert!(
302            matches!(&err, FraiseQLError::Unsupported { .. }),
303            "expected Unsupported error from mock adapter, got: {err:?}"
304        );
305    }
306
307    #[test]
308    fn test_build_display_sql_no_clause() {
309        let sql = super::build_display_sql("v_user", None, None, None);
310        assert_eq!(sql, "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT data FROM \"v_user\"");
311    }
312
313    #[test]
314    fn test_build_display_sql_with_limit_offset() {
315        let vars = json!({"status": "active"});
316        let sql = super::build_display_sql("v_user", Some(&vars), Some(10), Some(20));
317        assert!(sql.contains("LIMIT $2"), "should contain LIMIT $2, got: {sql}");
318        assert!(sql.contains("OFFSET $3"), "should contain OFFSET $3, got: {sql}");
319    }
320}