use std::fmt::Write as _;
use super::Executor;
use crate::{
db::{WhereClause, WhereOperator, traits::DatabaseAdapter},
error::{FraiseQLError, Result},
runtime::explain::ExplainResult,
};
impl<A: DatabaseAdapter> Executor<A> {
pub async fn explain(
&self,
query_name: &str,
variables: Option<&serde_json::Value>,
limit: Option<u32>,
offset: Option<u32>,
) -> Result<ExplainResult> {
if self.schema.mutations.iter().any(|m| m.name == query_name) {
return Err(FraiseQLError::Validation {
message: format!(
"EXPLAIN ANALYZE is not supported for mutations. \
'{query_name}' is a mutation; only regular queries are supported."
),
path: None,
});
}
let query_def =
self.schema.queries.iter().find(|q| q.name == query_name).ok_or_else(|| {
let display_names: Vec<String> =
self.schema.queries.iter().map(|q| self.schema.display_name(&q.name)).collect();
let candidate_refs: Vec<&str> = display_names.iter().map(String::as_str).collect();
let suggestion = crate::runtime::suggest_similar(query_name, &candidate_refs);
let message = match suggestion.as_slice() {
[s] => format!("Query '{query_name}' not found in schema. Did you mean '{s}'?"),
_ => format!("Query '{query_name}' not found in schema"),
};
FraiseQLError::Validation {
message,
path: None,
}
})?;
let sql_source =
query_def.sql_source.as_ref().ok_or_else(|| FraiseQLError::Validation {
message: format!("Query '{query_name}' has no SQL source"),
path: None,
})?;
let where_clause = build_where_from_variables(variables);
let parameters = collect_parameter_values(variables);
let generated_sql = build_display_sql(sql_source, variables, limit, offset);
let explain_output = self
.adapter
.explain_where_query(sql_source, where_clause.as_ref(), limit, offset)
.await?;
Ok(ExplainResult {
query_name: query_name.to_string(),
sql_source: sql_source.clone(),
generated_sql,
parameters,
explain_output,
})
}
}
fn build_where_from_variables(variables: Option<&serde_json::Value>) -> Option<WhereClause> {
let map = variables?.as_object()?;
if map.is_empty() {
return None;
}
let mut conditions: Vec<WhereClause> = map
.iter()
.map(|(k, v)| WhereClause::Field {
path: vec![k.clone()],
operator: WhereOperator::Eq,
value: v.clone(),
})
.collect();
if conditions.len() == 1 {
conditions.pop()
} else {
Some(WhereClause::And(conditions))
}
}
fn collect_parameter_values(variables: Option<&serde_json::Value>) -> Vec<serde_json::Value> {
variables
.and_then(|v| v.as_object())
.map(|map| map.values().cloned().collect())
.unwrap_or_default()
}
fn build_display_sql(
sql_source: &str,
variables: Option<&serde_json::Value>,
limit: Option<u32>,
offset: Option<u32>,
) -> String {
let mut sql =
format!("EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT data FROM \"{sql_source}\"");
if let Some(map) = variables.and_then(|v| v.as_object()) {
if !map.is_empty() {
let conditions: Vec<String> = map
.keys()
.enumerate()
.map(|(i, k)| format!("data->>'{}' = ${}", k, i + 1))
.collect();
sql.push_str(" WHERE ");
sql.push_str(&conditions.join(" AND "));
}
}
let param_offset = variables.and_then(|v| v.as_object()).map_or(0, |m| m.len());
if let Some(lim) = limit {
let _ = write!(sql, " LIMIT ${}", param_offset + 1);
let _ = lim; }
if let Some(off) = offset {
let limit_added = usize::from(limit.is_some());
let _ = write!(sql, " OFFSET ${}", param_offset + limit_added + 1);
let _ = off;
}
sql
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::json;
use crate::{
db::{
DatabaseType, PoolMetrics, WhereClause,
types::{JsonbValue, OrderByClause},
},
error::{FraiseQLError, Result},
runtime::Executor,
schema::{CompiledSchema, MutationDefinition, QueryDefinition},
};
struct MockAdapter;
#[async_trait]
impl crate::db::traits::DatabaseAdapter for MockAdapter {
async fn execute_where_query(
&self,
_view: &str,
_where_clause: Option<&WhereClause>,
_limit: Option<u32>,
_offset: Option<u32>,
_order_by: Option<&[OrderByClause]>,
) -> Result<Vec<JsonbValue>> {
Ok(vec![])
}
async fn execute_with_projection(
&self,
_view: &str,
_projection: Option<&crate::schema::SqlProjectionHint>,
_where_clause: Option<&WhereClause>,
_limit: Option<u32>,
_offset: Option<u32>,
_order_by: Option<&[OrderByClause]>,
) -> Result<Vec<JsonbValue>> {
Ok(vec![])
}
fn database_type(&self) -> DatabaseType {
DatabaseType::SQLite
}
async fn health_check(&self) -> Result<()> {
Ok(())
}
fn pool_metrics(&self) -> PoolMetrics {
PoolMetrics {
total_connections: 1,
idle_connections: 1,
active_connections: 0,
waiting_requests: 0,
}
}
async fn execute_raw_query(
&self,
_sql: &str,
) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
Ok(vec![])
}
async fn execute_parameterized_aggregate(
&self,
_sql: &str,
_params: &[serde_json::Value],
) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
Ok(vec![])
}
}
fn make_schema_with_query(name: &str, sql_source: &str) -> CompiledSchema {
let mut schema = CompiledSchema::default();
let mut qd = QueryDefinition::new(name, "SomeType");
qd.sql_source = Some(sql_source.to_string());
schema.queries.push(qd);
schema
}
fn make_schema_with_mutation(name: &str) -> CompiledSchema {
let mut schema = CompiledSchema::default();
let mut md = MutationDefinition::new(name, "MutationResponse");
md.sql_source = Some(format!("fn_{name}"));
schema.mutations.push(md);
schema
}
#[tokio::test]
async fn test_explain_unknown_query_returns_error() {
let schema = make_schema_with_query("users", "v_user");
let executor = Executor::new(schema, Arc::new(MockAdapter));
let err = executor.explain("nonexistent", None, None, None).await.unwrap_err();
assert!(
matches!(&err, FraiseQLError::Validation { message, .. } if message.contains("nonexistent")),
"expected Validation error mentioning the query name, got: {err:?}"
);
}
#[tokio::test]
async fn test_explain_mutation_returns_error() {
let schema = make_schema_with_mutation("createUser");
let executor = Executor::new(schema, Arc::new(MockAdapter));
let err = executor.explain("createUser", None, None, None).await.unwrap_err();
assert!(
matches!(&err, FraiseQLError::Validation { message, .. } if message.contains("mutation")),
"expected Validation error mentioning mutation, got: {err:?}"
);
}
#[tokio::test]
async fn test_explain_unsupported_adapter_returns_error() {
let schema = make_schema_with_query("users", "v_user");
let executor = Executor::new(schema, Arc::new(MockAdapter));
let err = executor
.explain("users", Some(&json!({"status": "active"})), Some(10), None)
.await
.unwrap_err();
assert!(
matches!(&err, FraiseQLError::Unsupported { .. }),
"expected Unsupported error from mock adapter, got: {err:?}"
);
}
#[test]
fn test_build_display_sql_no_clause() {
let sql = super::build_display_sql("v_user", None, None, None);
assert_eq!(sql, "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT data FROM \"v_user\"");
}
#[test]
fn test_build_display_sql_with_limit_offset() {
let vars = json!({"status": "active"});
let sql = super::build_display_sql("v_user", Some(&vars), Some(10), Some(20));
assert!(sql.contains("LIMIT $2"), "should contain LIMIT $2, got: {sql}");
assert!(sql.contains("OFFSET $3"), "should contain OFFSET $3, got: {sql}");
}
}