Skip to main content

uqa_planner/
explain.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Explain result rendering and query-block limit placement.
8
9use std::fmt::Write as _;
10use uqa_core::Value;
11use uqa_sql::{
12    plan::{QueryBlockPlan, QueryPlan, RelationalPlan, UnifiedPlan},
13    ResultRow, SQLError, SQLResult, ScalarExpr,
14};
15
16pub use uqa_sql::result::ExplainAnalysis;
17
18pub fn run_explain(
19    body: &UnifiedPlan,
20    verbose: bool,
21    format: Option<&str>,
22    analysis: Option<&ExplainAnalysis>,
23) -> Result<SQLResult, SQLError> {
24    let mut plan_text = match body {
25        UnifiedPlan::Query(query) => format_query_plan(query),
26        UnifiedPlan::Command(command) => format!("{}\n  {command:#?}", command.name()),
27    };
28    if verbose {
29        plan_text.push_str("\n  verbose=true");
30        write!(plan_text, "\n  physical_plan={body:#?}")
31            .map_err(|error| SQLError::Internal(format!("format EXPLAIN plan: {error}")))?;
32    }
33    if let Some(analysis) = analysis {
34        let _ = write!(
35            plan_text,
36            "\n  actual_rows={}\n  affected_rows={}\n  execution_time_ms={:.3}",
37            analysis.rows,
38            analysis.affected_rows,
39            analysis.elapsed.as_secs_f64() * 1_000.0
40        );
41    }
42
43    let format = format.unwrap_or("text").to_ascii_lowercase();
44    if format == "json" {
45        let payload = serde_json::json!({
46            "Plan": plan_text.lines().collect::<Vec<_>>(),
47            "Analyze": analysis.is_some(),
48            "Actual Rows": analysis.map(|value| value.rows),
49            "Affected Rows": analysis.map(|value| value.affected_rows),
50            "Execution Time (ms)": analysis.map(|value| value.elapsed.as_secs_f64() * 1_000.0),
51        });
52        let mut row = ResultRow::new();
53        row.insert("plan".to_string(), Value::Str(payload.to_string()));
54        return Ok(SQLResult {
55            kind: uqa_sql::SQLResultKind::Rows,
56            command_tag: None,
57            columns: vec!["plan".to_string()],
58            column_types: vec![Some(uqa_sql::ColumnType::Text)],
59            rows: vec![row],
60            positional_rows: None,
61            affected_rows: 0,
62        });
63    }
64    if format != "text" {
65        return Err(SQLError::Unsupported(format!(
66            "EXPLAIN format `{format}` is not supported; expected TEXT or JSON"
67        )));
68    }
69    let mut rows: Vec<ResultRow> = Vec::new();
70    for line in plan_text.split('\n') {
71        let mut r = ResultRow::new();
72        r.insert("plan".to_string(), Value::Str(line.to_string()));
73        rows.push(r);
74    }
75    Ok(SQLResult {
76        kind: uqa_sql::SQLResultKind::Rows,
77        command_tag: None,
78        columns: vec!["plan".to_string()],
79        column_types: vec![Some(uqa_sql::ColumnType::Text)],
80        rows,
81        positional_rows: None,
82        affected_rows: 0,
83    })
84}
85
86pub fn format_query_plan(plan: &QueryPlan) -> String {
87    match &plan.root {
88        RelationalPlan::QueryBlock(block) => format_select_plan(block),
89        RelationalPlan::SetOp {
90            kind,
91            all,
92            left,
93            right,
94            order_by,
95            limit,
96            offset,
97            ..
98        } => format!(
99            "SetOp\n  kind={kind:?}\n  all={all}\n  left=({})\n  right=({})\n  order_by={}\n  limit={}\n  offset={}",
100            format_query_plan(left).replace('\n', "\n    "),
101            format_query_plan(right).replace('\n', "\n    "),
102            order_by.len(),
103            limit
104                .as_deref()
105                .map_or_else(|| "none".into(), explain_int_expr),
106            offset
107                .as_deref()
108                .map_or_else(|| "none".into(), explain_int_expr),
109        ),
110        RelationalPlan::Values { rows, .. } => format!("Values\n  rows={}", rows.len()),
111    }
112}
113
114pub fn format_select_plan(stmt: &QueryBlockPlan) -> String {
115    use std::fmt::Write as _;
116    let mut s = String::new();
117    let _ = writeln!(s, "Select");
118    if !stmt.projections.is_empty() {
119        let _ = writeln!(s, "  projections={}", stmt.projections.len());
120    }
121    if let Some(from) = &stmt.from {
122        let _ = writeln!(s, "  from={from:?}");
123    }
124    if stmt.r#where.is_some() {
125        let _ = writeln!(s, "  where=<expr>");
126    }
127    if !stmt.group_by.is_empty() {
128        let _ = writeln!(s, "  group_by={}", stmt.group_by.len());
129    }
130    if !stmt.grouping_sets.is_empty() {
131        let _ = writeln!(s, "  grouping_sets={}", stmt.grouping_sets.len());
132    }
133    if !stmt.order_by.is_empty() {
134        let _ = writeln!(s, "  order_by={}", stmt.order_by.len());
135    }
136    if let Some(expr) = stmt.limit.as_ref() {
137        let _ = writeln!(s, "  limit={}", explain_int_expr(expr));
138    }
139    if let Some(expr) = stmt.offset.as_ref() {
140        let _ = writeln!(s, "  offset={}", explain_int_expr(expr));
141    }
142    if stmt.distinct {
143        let _ = writeln!(s, "  distinct=true");
144    }
145    if !stmt.locking.is_empty() {
146        let _ = writeln!(s, "  locking={}", stmt.locking.len());
147    }
148    s.trim_end().to_string()
149}
150
151pub use uqa_sql::semantics::{select_execution_stmt, should_defer_distinct_limit};
152
153pub fn explain_int_expr(expr: &ScalarExpr) -> String {
154    match expr {
155        ScalarExpr::Literal(Value::Int(n)) => n.to_string(),
156        _ => "<expr>".to_string(),
157    }
158}