Skip to main content

lora_compiler/
plan_tree.rs

1//! Public-API-friendly mirror of a [`CompiledQuery`]'s operator tree.
2//!
3//! `PlanTree` walks the physical plan and produces a flat, serializable
4//! description suitable for surfacing through `Database::explain` /
5//! `Database::profile` and onwards through the language bindings.
6//!
7//! The internal `PhysicalOp` nodes reference analyzer-internal types
8//! (`ResolvedExpr`, `VarId`); we deliberately do not expose those. Each
9//! operator becomes a `PlanTreeNode` with an opaque, human-readable
10//! `details` map keyed on stable strings. Future cost-modelling can fill
11//! in `estimated_rows` without breaking the type.
12use std::collections::BTreeMap;
13use std::fmt::Write as _;
14
15use lora_analyzer::ResolvedExpr;
16use lora_ast::Direction;
17
18use crate::physical::{PhysicalNodeId, PhysicalOp, PhysicalPlan};
19use crate::{CompiledQuery, CompiledUnionBranch};
20
21/// One node in the rendered plan tree.
22#[derive(Debug, Clone)]
23pub struct PlanTreeNode {
24    /// Stable `PhysicalNodeId` within the owning plan. Synthetic
25    /// nodes (e.g. the Union root, branch wrappers) reuse a sentinel
26    /// id of `usize::MAX`.
27    pub id: usize,
28    /// Operator label, e.g. `NodeByLabelScan`, `Expand`, `Projection`.
29    pub operator: String,
30    /// Human-readable operator details. Values are stringified so the
31    /// public API never leaks internal expression / `VarId` types.
32    pub details: BTreeMap<String, String>,
33    /// Reserved for a future cost model. Always `None` today.
34    pub estimated_rows: Option<u64>,
35    /// Children in physical execution order (leaf-most first).
36    pub children: Vec<PlanTreeNode>,
37}
38
39/// Top-level plan tree.
40#[derive(Debug, Clone)]
41pub struct PlanTree {
42    pub root: PlanTreeNode,
43}
44
45const SYNTHETIC_ID: usize = usize::MAX;
46
47/// Build a `PlanTree` from a compiled query, including UNION branches.
48pub fn plan_tree_from_compiled(compiled: &CompiledQuery) -> PlanTree {
49    let head = build_node(&compiled.physical, compiled.physical.root);
50    if compiled.unions.is_empty() {
51        return PlanTree { root: head };
52    }
53
54    let mut children = Vec::with_capacity(compiled.unions.len() + 1);
55    children.push(head);
56    for branch in &compiled.unions {
57        children.push(build_union_branch(branch));
58    }
59    let mut details = BTreeMap::new();
60    let all = compiled.unions.iter().all(|b| b.all);
61    let any_distinct = compiled.unions.iter().any(|b| !b.all);
62    let kind = if all && !any_distinct {
63        "ALL"
64    } else if any_distinct && compiled.unions.iter().all(|b| !b.all) {
65        "DISTINCT"
66    } else {
67        "MIXED"
68    };
69    details.insert("kind".to_string(), kind.to_string());
70    PlanTree {
71        root: PlanTreeNode {
72            id: SYNTHETIC_ID,
73            operator: "Union".to_string(),
74            details,
75            estimated_rows: None,
76            children,
77        },
78    }
79}
80
81fn build_union_branch(branch: &CompiledUnionBranch) -> PlanTreeNode {
82    let mut details = BTreeMap::new();
83    details.insert(
84        "kind".to_string(),
85        if branch.all { "ALL" } else { "DISTINCT" }.to_string(),
86    );
87    PlanTreeNode {
88        id: SYNTHETIC_ID,
89        operator: "UnionBranch".to_string(),
90        details,
91        estimated_rows: None,
92        children: vec![build_node(&branch.physical, branch.physical.root)],
93    }
94}
95
96fn build_node(plan: &PhysicalPlan, id: PhysicalNodeId) -> PlanTreeNode {
97    let op = &plan.nodes[id];
98    let (operator, details, child_ids) = describe(op);
99    let children = child_ids
100        .into_iter()
101        .map(|cid| build_node(plan, cid))
102        .collect();
103    PlanTreeNode {
104        id,
105        operator,
106        details,
107        estimated_rows: None,
108        children,
109    }
110}
111
112fn describe(op: &PhysicalOp) -> (String, BTreeMap<String, String>, Vec<PhysicalNodeId>) {
113    let mut d = BTreeMap::new();
114    match op {
115        PhysicalOp::Argument(_) => ("Argument".to_string(), d, Vec::new()),
116        PhysicalOp::NodeScan(n) => {
117            d.insert("var".to_string(), var_str(n.var));
118            ("NodeScan".to_string(), d, opt_input(n.input))
119        }
120        PhysicalOp::NodeByLabelScan(n) => {
121            d.insert("var".to_string(), var_str(n.var));
122            d.insert("labels".to_string(), label_groups_str(&n.labels));
123            ("NodeByLabelScan".to_string(), d, opt_input(n.input))
124        }
125        PhysicalOp::NodeByPropertyScan(n) => {
126            d.insert("var".to_string(), var_str(n.var));
127            if !n.labels.is_empty() {
128                d.insert("labels".to_string(), label_groups_str(&n.labels));
129            }
130            d.insert("key".to_string(), n.key.clone());
131            d.insert("value".to_string(), expr_str(&n.value));
132            ("NodeByPropertyScan".to_string(), d, opt_input(n.input))
133        }
134        PhysicalOp::Expand(n) => {
135            d.insert("src".to_string(), var_str(n.src));
136            d.insert("dst".to_string(), var_str(n.dst));
137            if let Some(rel) = n.rel {
138                d.insert("rel".to_string(), var_str(rel));
139            }
140            if !n.types.is_empty() {
141                d.insert("types".to_string(), n.types.join("|"));
142            }
143            d.insert(
144                "direction".to_string(),
145                direction_str(n.direction).to_string(),
146            );
147            if let Some(props) = &n.rel_properties {
148                d.insert("rel_properties".to_string(), expr_str(props));
149            }
150            if let Some(range) = &n.range {
151                d.insert("range".to_string(), format!("{:?}", range));
152            }
153            ("Expand".to_string(), d, vec![n.input])
154        }
155        PhysicalOp::Filter(n) => {
156            d.insert("predicate".to_string(), expr_str(&n.predicate));
157            ("Filter".to_string(), d, vec![n.input])
158        }
159        PhysicalOp::Projection(n) => {
160            d.insert("distinct".to_string(), n.distinct.to_string());
161            d.insert(
162                "include_existing".to_string(),
163                n.include_existing.to_string(),
164            );
165            d.insert(
166                "items".to_string(),
167                n.items
168                    .iter()
169                    .map(|p| p.name.clone())
170                    .collect::<Vec<_>>()
171                    .join(", "),
172            );
173            ("Projection".to_string(), d, vec![n.input])
174        }
175        PhysicalOp::Unwind(n) => {
176            d.insert("alias".to_string(), var_str(n.alias));
177            d.insert("expr".to_string(), expr_str(&n.expr));
178            ("Unwind".to_string(), d, vec![n.input])
179        }
180        PhysicalOp::HashAggregation(n) => {
181            d.insert(
182                "group_by".to_string(),
183                n.group_by
184                    .iter()
185                    .map(|p| p.name.clone())
186                    .collect::<Vec<_>>()
187                    .join(", "),
188            );
189            d.insert(
190                "aggregates".to_string(),
191                n.aggregates
192                    .iter()
193                    .map(|p| p.name.clone())
194                    .collect::<Vec<_>>()
195                    .join(", "),
196            );
197            ("HashAggregation".to_string(), d, vec![n.input])
198        }
199        PhysicalOp::Sort(n) => {
200            d.insert(
201                "items".to_string(),
202                format!("{} sort key(s)", n.items.len()),
203            );
204            ("Sort".to_string(), d, vec![n.input])
205        }
206        PhysicalOp::Limit(n) => {
207            if let Some(skip) = &n.skip {
208                d.insert("skip".to_string(), expr_str(skip));
209            }
210            if let Some(limit) = &n.limit {
211                d.insert("limit".to_string(), expr_str(limit));
212            }
213            ("Limit".to_string(), d, vec![n.input])
214        }
215        PhysicalOp::Create(n) => {
216            d.insert(
217                "elements".to_string(),
218                pattern_summary(n.pattern.parts.len()),
219            );
220            ("Create".to_string(), d, vec![n.input])
221        }
222        PhysicalOp::Merge(n) => {
223            d.insert(
224                "actions".to_string(),
225                if n.actions.is_empty() {
226                    "0".to_string()
227                } else {
228                    n.actions.len().to_string()
229                },
230            );
231            let _ = &n.pattern_part;
232            ("Merge".to_string(), d, vec![n.input])
233        }
234        PhysicalOp::Delete(n) => {
235            d.insert("detach".to_string(), n.detach.to_string());
236            d.insert("targets".to_string(), n.expressions.len().to_string());
237            ("Delete".to_string(), d, vec![n.input])
238        }
239        PhysicalOp::Set(n) => {
240            d.insert("items".to_string(), n.items.len().to_string());
241            ("Set".to_string(), d, vec![n.input])
242        }
243        PhysicalOp::Remove(n) => {
244            d.insert("items".to_string(), n.items.len().to_string());
245            ("Remove".to_string(), d, vec![n.input])
246        }
247        PhysicalOp::OptionalMatch(n) => {
248            d.insert(
249                "new_vars".to_string(),
250                n.new_vars
251                    .iter()
252                    .copied()
253                    .map(var_str)
254                    .collect::<Vec<_>>()
255                    .join(", "),
256            );
257            ("OptionalMatch".to_string(), d, vec![n.input, n.inner])
258        }
259        PhysicalOp::PathBuild(n) => {
260            d.insert("output".to_string(), var_str(n.output));
261            d.insert("nodes".to_string(), n.node_vars.len().to_string());
262            d.insert("rels".to_string(), n.rel_vars.len().to_string());
263            if let Some(all) = n.shortest_path_all {
264                d.insert("shortest_path_all".to_string(), all.to_string());
265            }
266            ("PathBuild".to_string(), d, vec![n.input])
267        }
268    }
269}
270
271fn opt_input(input: Option<PhysicalNodeId>) -> Vec<PhysicalNodeId> {
272    input.map(|i| vec![i]).unwrap_or_default()
273}
274
275fn var_str(v: lora_analyzer::symbols::VarId) -> String {
276    format!("v{}", v.0)
277}
278
279fn label_groups_str(groups: &[Vec<String>]) -> String {
280    groups
281        .iter()
282        .map(|or_group| or_group.join("|"))
283        .collect::<Vec<_>>()
284        .join("&")
285}
286
287fn direction_str(d: Direction) -> &'static str {
288    match d {
289        Direction::Right => "->",
290        Direction::Left => "<-",
291        Direction::Undirected => "-",
292    }
293}
294
295fn expr_str(e: &ResolvedExpr) -> String {
296    let mut out = String::new();
297    let _ = write!(&mut out, "{:?}", e);
298    out
299}
300
301fn pattern_summary(part_count: usize) -> String {
302    format!("{} pattern part(s)", part_count)
303}