1use std::collections::BTreeMap;
13use std::fmt::Write as _;
14
15use lora_analyzer::{ResolvedExpr, ResolvedProjection};
16use lora_ast::Direction;
17
18use crate::physical::{PhysicalNodeId, PhysicalOp, PhysicalPlan};
19use crate::{CompiledQuery, CompiledUnionBranch};
20
21#[derive(Debug, Clone)]
23pub struct PlanTreeNode {
24 pub id: usize,
28 pub operator: String,
30 pub details: BTreeMap<String, String>,
33 pub estimated_rows: Option<u64>,
35 pub children: Vec<PlanTreeNode>,
37}
38
39#[derive(Debug, Clone)]
41pub struct PlanTree {
42 pub root: PlanTreeNode,
43}
44
45const SYNTHETIC_ID: usize = usize::MAX;
46
47pub 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 details.insert("kind".to_string(), union_kind(&compiled.unions).to_string());
61 PlanTree {
62 root: PlanTreeNode {
63 id: SYNTHETIC_ID,
64 operator: "Union".to_string(),
65 details,
66 estimated_rows: None,
67 children,
68 },
69 }
70}
71
72fn build_union_branch(branch: &CompiledUnionBranch) -> PlanTreeNode {
73 let mut details = BTreeMap::new();
74 details.insert(
75 "kind".to_string(),
76 if branch.all { "ALL" } else { "DISTINCT" }.to_string(),
77 );
78 PlanTreeNode {
79 id: SYNTHETIC_ID,
80 operator: "UnionBranch".to_string(),
81 details,
82 estimated_rows: None,
83 children: vec![build_node(&branch.physical, branch.physical.root)],
84 }
85}
86
87fn build_node(plan: &PhysicalPlan, id: PhysicalNodeId) -> PlanTreeNode {
88 let op = &plan.nodes[id];
89 let description = describe(op);
90 let children = description
91 .child_ids
92 .into_iter()
93 .map(|cid| build_node(plan, cid))
94 .collect();
95 PlanTreeNode {
96 id,
97 operator: description.operator,
98 details: description.details,
99 estimated_rows: None,
100 children,
101 }
102}
103
104struct PlanDescription {
105 operator: String,
106 details: BTreeMap<String, String>,
107 child_ids: Vec<PhysicalNodeId>,
108}
109
110impl PlanDescription {
111 fn leaf(operator: &str) -> Self {
112 Self::new(operator, BTreeMap::new(), Vec::new())
113 }
114
115 fn with_children(
116 operator: &str,
117 details: BTreeMap<String, String>,
118 child_ids: Vec<PhysicalNodeId>,
119 ) -> Self {
120 Self::new(operator, details, child_ids)
121 }
122
123 fn new(
124 operator: &str,
125 details: BTreeMap<String, String>,
126 child_ids: Vec<PhysicalNodeId>,
127 ) -> Self {
128 Self {
129 operator: operator.to_string(),
130 details,
131 child_ids,
132 }
133 }
134}
135
136fn describe(op: &PhysicalOp) -> PlanDescription {
137 let mut d = BTreeMap::new();
138 match op {
139 PhysicalOp::Argument(_) => PlanDescription::leaf("Argument"),
140 PhysicalOp::NodeScan(n) => {
141 d.insert("var".to_string(), var_str(n.var));
142 PlanDescription::with_children("NodeScan", d, opt_input(n.input))
143 }
144 PhysicalOp::NodeByLabelScan(n) => {
145 d.insert("var".to_string(), var_str(n.var));
146 d.insert("labels".to_string(), label_groups_str(&n.labels));
147 PlanDescription::with_children("NodeByLabelScan", d, opt_input(n.input))
148 }
149 PhysicalOp::NodeByPropertyScan(n) => {
150 d.insert("var".to_string(), var_str(n.var));
151 if !n.labels.is_empty() {
152 d.insert("labels".to_string(), label_groups_str(&n.labels));
153 }
154 d.insert("key".to_string(), n.key.clone());
155 d.insert("value".to_string(), expr_str(&n.value));
156 PlanDescription::with_children("NodeByPropertyScan", d, opt_input(n.input))
157 }
158 PhysicalOp::NodeByPropertyRangeScan(n) => {
159 d.insert("var".to_string(), var_str(n.var));
160 if !n.labels.is_empty() {
161 d.insert("labels".to_string(), label_groups_str(&n.labels));
162 }
163 d.insert("key".to_string(), n.key.clone());
164 if let Some(lo) = &n.lo {
165 d.insert(
166 "lo".to_string(),
167 format!(
168 "{} {}",
169 if n.lo_inclusive { ">=" } else { ">" },
170 expr_str(lo)
171 ),
172 );
173 }
174 if let Some(hi) = &n.hi {
175 d.insert(
176 "hi".to_string(),
177 format!(
178 "{} {}",
179 if n.hi_inclusive { "<=" } else { "<" },
180 expr_str(hi)
181 ),
182 );
183 }
184 PlanDescription::with_children("NodeByPropertyRangeScan", d, opt_input(n.input))
185 }
186 PhysicalOp::NodeByPointScan(n) => {
187 d.insert("var".to_string(), var_str(n.var));
188 if !n.labels.is_empty() {
189 d.insert("labels".to_string(), label_groups_str(&n.labels));
190 }
191 d.insert("key".to_string(), n.key.clone());
192 match &n.predicate {
193 crate::PointPredicate::WithinBBox {
194 lower_left,
195 upper_right,
196 } => {
197 d.insert("predicate".to_string(), "withinBBox".to_string());
198 d.insert("lowerLeft".to_string(), expr_str(lower_left));
199 d.insert("upperRight".to_string(), expr_str(upper_right));
200 }
201 crate::PointPredicate::WithinDistance {
202 center,
203 max_distance,
204 inclusive,
205 } => {
206 d.insert(
207 "predicate".to_string(),
208 if *inclusive {
209 "distance<="
210 } else {
211 "distance<"
212 }
213 .to_string(),
214 );
215 d.insert("center".to_string(), expr_str(center));
216 d.insert("maxDistance".to_string(), expr_str(max_distance));
217 }
218 }
219 PlanDescription::with_children("NodeByPointScan", d, opt_input(n.input))
220 }
221 PhysicalOp::NodeByTextScan(n) => {
222 d.insert("var".to_string(), var_str(n.var));
223 if !n.labels.is_empty() {
224 d.insert("labels".to_string(), label_groups_str(&n.labels));
225 }
226 d.insert("key".to_string(), n.key.clone());
227 d.insert(
228 "predicate".to_string(),
229 match n.predicate {
230 crate::TextPredicate::StartsWith => "STARTS WITH",
231 crate::TextPredicate::EndsWith => "ENDS WITH",
232 crate::TextPredicate::Contains => "CONTAINS",
233 }
234 .to_string(),
235 );
236 d.insert("query".to_string(), expr_str(&n.query));
237 PlanDescription::with_children("NodeByTextScan", d, opt_input(n.input))
238 }
239 PhysicalOp::RelByPropertyRangeScan(n) => {
240 d.insert("rel".to_string(), var_str(n.rel));
241 d.insert("src".to_string(), var_str(n.src));
242 d.insert("dst".to_string(), var_str(n.dst));
243 if !n.types.is_empty() {
244 d.insert("types".to_string(), n.types.join("|"));
245 }
246 d.insert(
247 "direction".to_string(),
248 direction_str(n.direction).to_string(),
249 );
250 d.insert("key".to_string(), n.key.clone());
251 if let Some(lo) = &n.lo {
252 d.insert(
253 "lo".to_string(),
254 format!(
255 "{} {}",
256 if n.lo_inclusive { ">=" } else { ">" },
257 expr_str(lo)
258 ),
259 );
260 }
261 if let Some(hi) = &n.hi {
262 d.insert(
263 "hi".to_string(),
264 format!(
265 "{} {}",
266 if n.hi_inclusive { "<=" } else { "<" },
267 expr_str(hi)
268 ),
269 );
270 }
271 PlanDescription::with_children("RelByPropertyRangeScan", d, opt_input(n.input))
272 }
273 PhysicalOp::RelByTextScan(n) => {
274 d.insert("rel".to_string(), var_str(n.rel));
275 d.insert("src".to_string(), var_str(n.src));
276 d.insert("dst".to_string(), var_str(n.dst));
277 if !n.types.is_empty() {
278 d.insert("types".to_string(), n.types.join("|"));
279 }
280 d.insert(
281 "direction".to_string(),
282 direction_str(n.direction).to_string(),
283 );
284 d.insert("key".to_string(), n.key.clone());
285 d.insert(
286 "predicate".to_string(),
287 match n.predicate {
288 crate::TextPredicate::StartsWith => "STARTS WITH",
289 crate::TextPredicate::EndsWith => "ENDS WITH",
290 crate::TextPredicate::Contains => "CONTAINS",
291 }
292 .to_string(),
293 );
294 d.insert("query".to_string(), expr_str(&n.query));
295 PlanDescription::with_children("RelByTextScan", d, opt_input(n.input))
296 }
297 PhysicalOp::RelByPointScan(n) => {
298 d.insert("rel".to_string(), var_str(n.rel));
299 d.insert("src".to_string(), var_str(n.src));
300 d.insert("dst".to_string(), var_str(n.dst));
301 if !n.types.is_empty() {
302 d.insert("types".to_string(), n.types.join("|"));
303 }
304 d.insert(
305 "direction".to_string(),
306 direction_str(n.direction).to_string(),
307 );
308 d.insert("key".to_string(), n.key.clone());
309 match &n.predicate {
310 crate::PointPredicate::WithinBBox {
311 lower_left,
312 upper_right,
313 } => {
314 d.insert("predicate".to_string(), "withinBBox".to_string());
315 d.insert("lowerLeft".to_string(), expr_str(lower_left));
316 d.insert("upperRight".to_string(), expr_str(upper_right));
317 }
318 crate::PointPredicate::WithinDistance {
319 center,
320 max_distance,
321 inclusive,
322 } => {
323 d.insert(
324 "predicate".to_string(),
325 if *inclusive {
326 "distance<="
327 } else {
328 "distance<"
329 }
330 .to_string(),
331 );
332 d.insert("center".to_string(), expr_str(center));
333 d.insert("maxDistance".to_string(), expr_str(max_distance));
334 }
335 }
336 PlanDescription::with_children("RelByPointScan", d, opt_input(n.input))
337 }
338 PhysicalOp::Expand(n) => describe_expand(n),
339 PhysicalOp::Filter(n) => {
340 d.insert("predicate".to_string(), expr_str(&n.predicate));
341 PlanDescription::with_children("Filter", d, vec![n.input])
342 }
343 PhysicalOp::Projection(n) => describe_projection(n),
344 PhysicalOp::Unwind(n) => {
345 d.insert("alias".to_string(), var_str(n.alias));
346 d.insert("expr".to_string(), expr_str(&n.expr));
347 PlanDescription::with_children("Unwind", d, vec![n.input])
348 }
349 PhysicalOp::HashAggregation(n) => describe_hash_aggregation(n),
350 PhysicalOp::Sort(n) => {
351 d.insert(
352 "items".to_string(),
353 format!("{} sort key(s)", n.items.len()),
354 );
355 if let Some(top_k) = n.top_k {
356 d.insert("top_k".to_string(), top_k.to_string());
357 }
358 PlanDescription::with_children("Sort", d, vec![n.input])
359 }
360 PhysicalOp::Limit(n) => {
361 if let Some(skip) = &n.skip {
362 d.insert("skip".to_string(), expr_str(skip));
363 }
364 if let Some(limit) = &n.limit {
365 d.insert("limit".to_string(), expr_str(limit));
366 }
367 PlanDescription::with_children("Limit", d, vec![n.input])
368 }
369 PhysicalOp::Create(n) => {
370 d.insert(
371 "elements".to_string(),
372 pattern_summary(n.pattern.parts.len()),
373 );
374 PlanDescription::with_children("Create", d, vec![n.input])
375 }
376 PhysicalOp::Merge(n) => describe_merge(n),
377 PhysicalOp::Delete(n) => {
378 d.insert("detach".to_string(), n.detach.to_string());
379 d.insert("targets".to_string(), n.expressions.len().to_string());
380 PlanDescription::with_children("Delete", d, vec![n.input])
381 }
382 PhysicalOp::Set(n) => {
383 d.insert("items".to_string(), n.items.len().to_string());
384 PlanDescription::with_children("Set", d, vec![n.input])
385 }
386 PhysicalOp::Remove(n) => {
387 d.insert("items".to_string(), n.items.len().to_string());
388 PlanDescription::with_children("Remove", d, vec![n.input])
389 }
390 PhysicalOp::Foreach(n) => {
391 d.insert("variable".to_string(), var_str(n.variable));
392 d.insert("list".to_string(), expr_str(&n.list));
393 d.insert("body".to_string(), n.body.len().to_string());
394 PlanDescription::with_children("Foreach", d, vec![n.input])
395 }
396 PhysicalOp::OptionalMatch(n) => describe_optional_match(n),
397 PhysicalOp::CallSubquery(n) => {
398 d.insert(
399 "new_vars".to_string(),
400 n.new_vars
401 .iter()
402 .copied()
403 .map(var_str)
404 .collect::<Vec<_>>()
405 .join(", "),
406 );
407 PlanDescription::with_children("CallSubquery", d, vec![n.input, n.inner])
408 }
409 PhysicalOp::PathBuild(n) => {
410 d.insert("output".to_string(), var_str(n.output));
411 d.insert("nodes".to_string(), n.node_vars.len().to_string());
412 d.insert("rels".to_string(), n.rel_vars.len().to_string());
413 if let Some(all) = n.shortest_path_all {
414 d.insert("shortest_path_all".to_string(), all.to_string());
415 }
416 PlanDescription::with_children("PathBuild", d, vec![n.input])
417 }
418 }
419}
420
421fn union_kind(branches: &[CompiledUnionBranch]) -> &'static str {
422 let all = branches.iter().all(|b| b.all);
423 let all_distinct = branches.iter().all(|b| !b.all);
424 if all {
425 "ALL"
426 } else if all_distinct {
427 "DISTINCT"
428 } else {
429 "MIXED"
430 }
431}
432
433fn describe_expand(n: &crate::physical::ExpandExec) -> PlanDescription {
434 let mut d = BTreeMap::new();
435 d.insert("src".to_string(), var_str(n.src));
436 d.insert("dst".to_string(), var_str(n.dst));
437 if let Some(rel) = n.rel {
438 d.insert("rel".to_string(), var_str(rel));
439 }
440 if !n.types.is_empty() {
441 d.insert("types".to_string(), n.types.join("|"));
442 }
443 d.insert(
444 "direction".to_string(),
445 direction_str(n.direction).to_string(),
446 );
447 if let Some(props) = &n.rel_properties {
448 d.insert("rel_properties".to_string(), expr_str(props));
449 }
450 if let Some(range) = &n.range {
451 d.insert("range".to_string(), format!("{:?}", range));
452 }
453 PlanDescription::with_children("Expand", d, vec![n.input])
454}
455
456fn describe_projection(n: &crate::physical::ProjectionExec) -> PlanDescription {
457 let mut d = BTreeMap::new();
458 d.insert("distinct".to_string(), n.distinct.to_string());
459 d.insert(
460 "include_existing".to_string(),
461 n.include_existing.to_string(),
462 );
463 d.insert("items".to_string(), projection_names(&n.items));
464 PlanDescription::with_children("Projection", d, vec![n.input])
465}
466
467fn describe_hash_aggregation(n: &crate::physical::HashAggregationExec) -> PlanDescription {
468 let mut d = BTreeMap::new();
469 d.insert("group_by".to_string(), projection_names(&n.group_by));
470 d.insert("aggregates".to_string(), projection_names(&n.aggregates));
471 PlanDescription::with_children("HashAggregation", d, vec![n.input])
472}
473
474fn describe_merge(n: &crate::physical::MergeExec) -> PlanDescription {
475 let mut d = BTreeMap::new();
476 d.insert("actions".to_string(), n.actions.len().to_string());
477 PlanDescription::with_children("Merge", d, vec![n.input])
478}
479
480fn describe_optional_match(n: &crate::physical::OptionalMatchExec) -> PlanDescription {
481 let mut d = BTreeMap::new();
482 d.insert(
483 "new_vars".to_string(),
484 n.new_vars
485 .iter()
486 .copied()
487 .map(var_str)
488 .collect::<Vec<_>>()
489 .join(", "),
490 );
491 PlanDescription::with_children("OptionalMatch", d, vec![n.input, n.inner])
492}
493
494fn opt_input(input: Option<PhysicalNodeId>) -> Vec<PhysicalNodeId> {
495 input.map(|i| vec![i]).unwrap_or_default()
496}
497
498fn var_str(v: lora_analyzer::symbols::VarId) -> String {
499 format!("v{}", v.0)
500}
501
502fn label_groups_str(groups: &[Vec<String>]) -> String {
503 groups
504 .iter()
505 .map(|or_group| or_group.join("|"))
506 .collect::<Vec<_>>()
507 .join("&")
508}
509
510fn projection_names(items: &[ResolvedProjection]) -> String {
511 items
512 .iter()
513 .map(|p| p.name.clone())
514 .collect::<Vec<_>>()
515 .join(", ")
516}
517
518fn direction_str(d: Direction) -> &'static str {
519 match d {
520 Direction::Right => "->",
521 Direction::Left => "<-",
522 Direction::Undirected => "-",
523 }
524}
525
526fn expr_str(e: &ResolvedExpr) -> String {
527 let mut out = String::new();
528 let _ = write!(&mut out, "{:?}", e);
529 out
530}
531
532fn pattern_summary(part_count: usize) -> String {
533 format!("{} pattern part(s)", part_count)
534}