Skip to main content

oxisql_parse/
explain.rs

1//! Human-readable plan explanation for [`LogicalPlan`]s.
2
3use crate::cost::{CostModel, NodeCost};
4use crate::plan::{JoinType, LogicalPlan, SetOpType};
5
6// ── Plan explanation ─────────────────────────────────────────────────────────
7
8/// Render a [`LogicalPlan`] as a human-readable indented tree string.
9///
10/// Each level is indented by two spaces per depth.  Example:
11///
12/// ```text
13/// Limit [count=10]
14///   Sort [age ASC]
15///     Filter [age > 18]
16///       Scan [users]
17/// ```
18pub fn explain(plan: &LogicalPlan) -> String {
19    let mut out = String::new();
20    explain_inner(plan, 0, &mut out);
21    // Trim trailing newline for a clean return value.
22    if out.ends_with('\n') {
23        out.pop();
24    }
25    out
26}
27
28// ── Verbose explain ──────────────────────────────────────────────────────────
29
30/// Like [`explain`], but appends `(rows=N, cost=M)` to each line.
31///
32/// Costs are computed by the supplied [`CostModel`].
33pub fn explain_verbose(plan: &LogicalPlan, model: &CostModel) -> String {
34    let costs = model.explain_costs(plan);
35    let mut out = String::new();
36    explain_verbose_inner(plan, &costs, 0, &mut out);
37    if out.ends_with('\n') {
38        out.pop();
39    }
40    out
41}
42
43fn explain_verbose_inner(plan: &LogicalPlan, cost: &NodeCost, depth: usize, out: &mut String) {
44    // Capture the plain-explain line for this node, then strip its trailing
45    // newline and append the cost annotation before adding our own newline.
46    let mut line = String::new();
47    explain_inner(plan, depth, &mut line);
48    // `line` may end with '\n'; strip it.
49    let line = line.trim_end_matches('\n');
50
51    let rows = cost.estimate.rows as u64;
52    let cost_val = cost.estimate.total_cost;
53    out.push_str(&format!("{line} (rows={rows}, cost={cost_val:.1})\n"));
54
55    // Recurse into children, pairing plan children with cost children.
56    let plan_children = plan_children_ref(plan);
57    for (child_plan, child_cost) in plan_children.iter().zip(cost.children.iter()) {
58        explain_verbose_inner(child_plan, child_cost, depth + 1, out);
59    }
60}
61
62/// Return references to the direct plan children of `plan` in the same order
63/// that [`explain_inner`] recurses into them.
64fn plan_children_ref(plan: &LogicalPlan) -> Vec<&LogicalPlan> {
65    match plan {
66        LogicalPlan::Filter { input, .. }
67        | LogicalPlan::Project { input, .. }
68        | LogicalPlan::Aggregate { input, .. }
69        | LogicalPlan::Sort { input, .. }
70        | LogicalPlan::Limit { input, .. }
71        | LogicalPlan::Window { input, .. }
72        | LogicalPlan::Cte { query: input, .. }
73        | LogicalPlan::Subquery { query: input, .. }
74        | LogicalPlan::Exists {
75            subquery: input, ..
76        }
77        | LogicalPlan::Compute { input, .. } => vec![input.as_ref()],
78
79        LogicalPlan::InSubquery { subquery, .. } => vec![subquery.as_ref()],
80
81        LogicalPlan::Join { left, right, .. } | LogicalPlan::SetOp { left, right, .. } => {
82            vec![left.as_ref(), right.as_ref()]
83        }
84
85        LogicalPlan::Scan { .. }
86        | LogicalPlan::Values { .. }
87        | LogicalPlan::Empty
88        | LogicalPlan::CteRef { .. } => vec![],
89    }
90}
91
92// ── JSON explain ─────────────────────────────────────────────────────────────
93
94/// Emit the plan as JSON, optionally with per-node cost estimates.
95///
96/// Uses a hand-rolled JSON writer — no `serde_json` runtime dependency.
97/// Example output:
98/// ```json
99/// {"op":"Scan","table":"orders","rows":50000,"cost":50000.0,"children":[]}
100/// ```
101pub fn explain_json(plan: &LogicalPlan, model: Option<&CostModel>) -> String {
102    let costs = model.map(|m| m.explain_costs(plan));
103    let mut out = String::new();
104    emit_json(plan, costs.as_ref(), &mut out);
105    out
106}
107
108fn emit_json(plan: &LogicalPlan, cost: Option<&NodeCost>, out: &mut String) {
109    out.push('{');
110
111    // "op": "<name>"
112    let op_name = node_op_name(plan);
113    out.push_str("\"op\":");
114    json_string(op_name, out);
115
116    // Per-variant extra fields.
117    emit_json_fields(plan, out);
118
119    // Cost fields when a model is supplied.
120    if let Some(c) = cost {
121        let rows = c.estimate.rows as u64;
122        let cost_val = c.estimate.total_cost;
123        out.push_str(&format!(",\"rows\":{rows},\"cost\":{cost_val:.1}"));
124    }
125
126    // Children.
127    let plan_children = plan_children_ref(plan);
128    let cost_children: Vec<Option<&NodeCost>> = if let Some(c) = cost {
129        c.children.iter().map(Some).collect()
130    } else {
131        plan_children.iter().map(|_| None).collect()
132    };
133
134    out.push_str(",\"children\":[");
135    for (i, (child_plan, child_cost)) in plan_children.iter().zip(cost_children.iter()).enumerate()
136    {
137        if i > 0 {
138            out.push(',');
139        }
140        emit_json(child_plan, *child_cost, out);
141    }
142    out.push_str("]}");
143}
144
145/// Short operator name for the JSON `"op"` field.
146fn node_op_name(plan: &LogicalPlan) -> &'static str {
147    match plan {
148        LogicalPlan::Scan { .. } => "Scan",
149        LogicalPlan::Filter { .. } => "Filter",
150        LogicalPlan::Project { .. } => "Project",
151        LogicalPlan::Join { .. } => "Join",
152        LogicalPlan::Aggregate { .. } => "Aggregate",
153        LogicalPlan::Sort { .. } => "Sort",
154        LogicalPlan::Limit { .. } => "Limit",
155        LogicalPlan::Values { .. } => "Values",
156        LogicalPlan::Empty => "Empty",
157        LogicalPlan::SetOp { .. } => "SetOp",
158        LogicalPlan::Cte { .. } => "Cte",
159        LogicalPlan::CteRef { .. } => "CteRef",
160        LogicalPlan::Window { .. } => "Window",
161        LogicalPlan::Subquery { .. } => "Subquery",
162        LogicalPlan::Exists { .. } => "Exists",
163        LogicalPlan::InSubquery { .. } => "InSubquery",
164        LogicalPlan::Compute { .. } => "Compute",
165    }
166}
167
168/// Emit variant-specific JSON fields (after `"op"`, before `"rows"`/`"children"`).
169fn emit_json_fields(plan: &LogicalPlan, out: &mut String) {
170    match plan {
171        LogicalPlan::Scan {
172            table,
173            alias,
174            limit,
175        } => {
176            out.push_str(",\"table\":");
177            json_string(table, out);
178            if let Some(a) = alias {
179                out.push_str(",\"alias\":");
180                json_string(a, out);
181            }
182            if let Some(l) = limit {
183                out.push_str(&format!(",\"limit\":{l}"));
184            }
185        }
186        LogicalPlan::Filter { predicate, .. } => {
187            out.push_str(",\"predicate\":");
188            json_string(predicate, out);
189        }
190        LogicalPlan::Project { columns, .. } => {
191            out.push_str(",\"columns\":[");
192            for (i, c) in columns.iter().enumerate() {
193                if i > 0 {
194                    out.push(',');
195                }
196                json_string(c, out);
197            }
198            out.push(']');
199        }
200        LogicalPlan::Join { on, join_type, .. } => {
201            let jt = match join_type {
202                JoinType::Inner => "INNER",
203                JoinType::Left => "LEFT",
204                JoinType::Right => "RIGHT",
205                JoinType::Full => "FULL",
206                JoinType::Cross => "CROSS",
207                JoinType::LeftSemi => "SEMI",
208                JoinType::LeftAnti => "ANTI",
209            };
210            out.push_str(",\"join_type\":");
211            json_string(jt, out);
212            if !on.is_empty() {
213                out.push_str(",\"on\":");
214                json_string(on, out);
215            }
216        }
217        LogicalPlan::Aggregate {
218            group_by,
219            aggregates,
220            ..
221        } => {
222            out.push_str(",\"group_by\":[");
223            for (i, g) in group_by.iter().enumerate() {
224                if i > 0 {
225                    out.push(',');
226                }
227                json_string(g, out);
228            }
229            out.push_str("],\"aggregates\":[");
230            for (i, a) in aggregates.iter().enumerate() {
231                if i > 0 {
232                    out.push(',');
233                }
234                json_string(a, out);
235            }
236            out.push(']');
237        }
238        LogicalPlan::Sort { order_by, .. } => {
239            out.push_str(",\"order_by\":[");
240            for (i, s) in order_by.iter().enumerate() {
241                if i > 0 {
242                    out.push(',');
243                }
244                let dir = if s.ascending { "ASC" } else { "DESC" };
245                out.push('{');
246                out.push_str("\"column\":");
247                json_string(&s.column, out);
248                out.push_str(",\"dir\":");
249                json_string(dir, out);
250                out.push('}');
251            }
252            out.push(']');
253        }
254        LogicalPlan::Limit { count, offset, .. } => {
255            if let Some(c) = count {
256                out.push_str(&format!(",\"count\":{c}"));
257            }
258            if let Some(o) = offset {
259                out.push_str(&format!(",\"offset\":{o}"));
260            }
261        }
262        LogicalPlan::Values { columns, rows } => {
263            out.push_str(&format!(",\"row_count\":{rows}"));
264            out.push_str(",\"columns\":[");
265            for (i, c) in columns.iter().enumerate() {
266                if i > 0 {
267                    out.push(',');
268                }
269                json_string(c, out);
270            }
271            out.push(']');
272        }
273        LogicalPlan::SetOp { op, all, .. } => {
274            let op_str = match op {
275                SetOpType::Union => "UNION",
276                SetOpType::Intersect => "INTERSECT",
277                SetOpType::Except => "EXCEPT",
278            };
279            out.push_str(",\"set_op\":");
280            json_string(op_str, out);
281            out.push_str(&format!(",\"all\":{all}"));
282        }
283        LogicalPlan::Cte {
284            name, recursive, ..
285        } => {
286            out.push_str(",\"name\":");
287            json_string(name, out);
288            out.push_str(&format!(",\"recursive\":{recursive}"));
289        }
290        LogicalPlan::CteRef { name } => {
291            out.push_str(",\"name\":");
292            json_string(name, out);
293        }
294        LogicalPlan::Window { functions, .. } => {
295            out.push_str(",\"functions\":[");
296            for (i, f) in functions.iter().enumerate() {
297                if i > 0 {
298                    out.push(',');
299                }
300                out.push('{');
301                out.push_str("\"name\":");
302                json_string(&f.name, out);
303                out.push_str(",\"alias\":");
304                json_string(&f.alias, out);
305                out.push('}');
306            }
307            out.push(']');
308        }
309        LogicalPlan::Subquery { alias, .. } => {
310            if let Some(a) = alias {
311                out.push_str(",\"alias\":");
312                json_string(a, out);
313            }
314        }
315        LogicalPlan::Exists { negated, .. } => {
316            out.push_str(&format!(",\"negated\":{negated}"));
317        }
318        LogicalPlan::InSubquery { expr, negated, .. } => {
319            out.push_str(",\"expr\":");
320            json_string(expr, out);
321            out.push_str(&format!(",\"negated\":{negated}"));
322        }
323        LogicalPlan::Compute { bindings, .. } => {
324            out.push_str(",\"bindings\":[");
325            for (i, (alias, expr)) in bindings.iter().enumerate() {
326                if i > 0 {
327                    out.push(',');
328                }
329                out.push('{');
330                out.push_str("\"alias\":");
331                json_string(alias, out);
332                out.push_str(",\"expr\":");
333                json_string(expr, out);
334                out.push('}');
335            }
336            out.push(']');
337        }
338        LogicalPlan::Empty => {}
339    }
340}
341
342/// Write `s` as a JSON string literal into `out`, escaping special characters.
343fn json_string(s: &str, out: &mut String) {
344    out.push('"');
345    for ch in s.chars() {
346        match ch {
347            '"' => out.push_str("\\\""),
348            '\\' => out.push_str("\\\\"),
349            '\n' => out.push_str("\\n"),
350            '\r' => out.push_str("\\r"),
351            '\t' => out.push_str("\\t"),
352            c => out.push(c),
353        }
354    }
355    out.push('"');
356}
357
358// ── Plain explain ────────────────────────────────────────────────────────────
359
360pub(crate) fn explain_inner(plan: &LogicalPlan, depth: usize, out: &mut String) {
361    let indent = "  ".repeat(depth);
362    match plan {
363        LogicalPlan::Scan {
364            table,
365            alias,
366            limit,
367        } => {
368            let limit_str = limit.map(|l| format!(" limit={l}")).unwrap_or_default();
369            if let Some(a) = alias {
370                out.push_str(&format!("{indent}Scan [{table} AS {a}{limit_str}]\n"));
371            } else {
372                out.push_str(&format!("{indent}Scan [{table}{limit_str}]\n"));
373            }
374        }
375        LogicalPlan::Filter { input, predicate } => {
376            out.push_str(&format!("{indent}Filter [{predicate}]\n"));
377            explain_inner(input, depth + 1, out);
378        }
379        LogicalPlan::Project { input, columns } => {
380            let cols = columns.join(", ");
381            out.push_str(&format!("{indent}Project [{cols}]\n"));
382            explain_inner(input, depth + 1, out);
383        }
384        LogicalPlan::Join {
385            left,
386            right,
387            on,
388            join_type,
389            ..
390        } => {
391            let jt = match join_type {
392                JoinType::Inner => "INNER",
393                JoinType::Left => "LEFT",
394                JoinType::Right => "RIGHT",
395                JoinType::Full => "FULL",
396                JoinType::Cross => "CROSS",
397                JoinType::LeftSemi => "SEMI",
398                JoinType::LeftAnti => "ANTI",
399            };
400            if on.is_empty() {
401                out.push_str(&format!("{indent}Join [{jt}]\n"));
402            } else {
403                out.push_str(&format!("{indent}Join [{jt} ON {on}]\n"));
404            }
405            explain_inner(left, depth + 1, out);
406            explain_inner(right, depth + 1, out);
407        }
408        LogicalPlan::Aggregate {
409            input,
410            group_by,
411            aggregates,
412        } => {
413            let gb = group_by.join(", ");
414            let agg = aggregates.join(", ");
415            out.push_str(&format!(
416                "{indent}Aggregate [group_by=[{gb}] agg=[{agg}]]\n"
417            ));
418            explain_inner(input, depth + 1, out);
419        }
420        LogicalPlan::Sort { input, order_by } => {
421            let keys: Vec<String> = order_by
422                .iter()
423                .map(|s| {
424                    if s.ascending {
425                        format!("{} ASC", s.column)
426                    } else {
427                        format!("{} DESC", s.column)
428                    }
429                })
430                .collect();
431            out.push_str(&format!("{indent}Sort [{}]\n", keys.join(", ")));
432            explain_inner(input, depth + 1, out);
433        }
434        LogicalPlan::Limit {
435            input,
436            count,
437            offset,
438        } => {
439            let mut parts = Vec::new();
440            if let Some(c) = count {
441                parts.push(format!("count={c}"));
442            }
443            if let Some(o) = offset {
444                parts.push(format!("offset={o}"));
445            }
446            out.push_str(&format!("{indent}Limit [{}]\n", parts.join(", ")));
447            explain_inner(input, depth + 1, out);
448        }
449        LogicalPlan::Values { columns, rows } => {
450            let cols = if columns.is_empty() {
451                String::new()
452            } else {
453                format!(" cols=[{}]", columns.join(", "))
454            };
455            out.push_str(&format!("{indent}Values [rows={rows}{cols}]\n"));
456        }
457        LogicalPlan::Empty => {
458            out.push_str(&format!("{indent}Empty\n"));
459        }
460        LogicalPlan::SetOp {
461            op,
462            all,
463            left,
464            right,
465        } => {
466            let op_str = match op {
467                SetOpType::Union => "UNION",
468                SetOpType::Intersect => "INTERSECT",
469                SetOpType::Except => "EXCEPT",
470            };
471            let all_str = if *all { " ALL" } else { "" };
472            out.push_str(&format!("{indent}SetOp [{op_str}{all_str}]\n"));
473            explain_inner(left, depth + 1, out);
474            explain_inner(right, depth + 1, out);
475        }
476        LogicalPlan::Cte {
477            name,
478            query,
479            recursive,
480        } => {
481            let rec = if *recursive { " RECURSIVE" } else { "" };
482            out.push_str(&format!("{indent}Cte [{name}{rec}]\n"));
483            explain_inner(query, depth + 1, out);
484        }
485        LogicalPlan::CteRef { name } => {
486            out.push_str(&format!("{indent}CteRef [{name}]\n"));
487        }
488        LogicalPlan::Window { input, functions } => {
489            let names: Vec<&str> = functions.iter().map(|f| f.name.as_str()).collect();
490            out.push_str(&format!("{indent}Window [{}]\n", names.join(", ")));
491            explain_inner(input, depth + 1, out);
492        }
493        LogicalPlan::Subquery { query, alias } => {
494            if let Some(a) = alias {
495                out.push_str(&format!("{indent}Subquery [alias={a}]\n"));
496            } else {
497                out.push_str(&format!("{indent}Subquery\n"));
498            }
499            explain_inner(query, depth + 1, out);
500        }
501        LogicalPlan::Exists { subquery, negated } => {
502            let neg = if *negated { "NOT " } else { "" };
503            out.push_str(&format!("{indent}Exists [{neg}EXISTS]\n"));
504            explain_inner(subquery, depth + 1, out);
505        }
506        LogicalPlan::InSubquery {
507            expr,
508            subquery,
509            negated,
510        } => {
511            let neg = if *negated { "NOT " } else { "" };
512            out.push_str(&format!("{indent}InSubquery [{expr} {neg}IN]\n"));
513            explain_inner(subquery, depth + 1, out);
514        }
515        LogicalPlan::Compute { input, bindings } => {
516            let bind_str: Vec<String> = bindings
517                .iter()
518                .map(|(alias, expr)| format!("{alias} = {expr}"))
519                .collect();
520            out.push_str(&format!("{indent}Compute [{}]\n", bind_str.join(", ")));
521            explain_inner(input, depth + 1, out);
522        }
523    }
524}