Skip to main content

oxirs_arq/explain/
mod.rs

1//! # SPARQL Query Explain / Plan
2//!
3//! Provides SQL-style `EXPLAIN` functionality for SPARQL queries: the query
4//! optimiser produces a [`QueryPlan`] describing exactly which physical
5//! operators will be executed, and a [`QueryExplainer`] renders that plan in
6//! three formats (human-readable text, machine-readable JSON, Graphviz DOT).
7//!
8//! ## Quick start
9//!
10//! ```rust
11//! use oxirs_arq::explain::{
12//!     QueryPlan, PlanNode, IndexType, QueryExplainer, ExplainFormat,
13//! };
14//!
15//! let plan = QueryPlan {
16//!     root: PlanNode::TripleScan {
17//!         pattern: "?s rdf:type ?t".to_string(),
18//!         index_used: IndexType::Spo,
19//!         estimated_rows: 500,
20//!     },
21//!     estimated_cost: 1.5,
22//!     estimated_cardinality: 500,
23//! };
24//!
25//! let explainer = QueryExplainer::new();
26//! let text = explainer.explain(&plan);
27//! assert!(text.contains("TripleScan"));
28//! ```
29
30use serde::{Deserialize, Serialize};
31use std::fmt;
32
33// ── Public types ──────────────────────────────────────────────────────────────
34
35/// A complete, annotated query execution plan.
36///
37/// The plan is a tree of [`PlanNode`]s that mirrors the physical execution
38/// order decided by the optimizer.  Each node carries its own cost / row
39/// estimate so callers can identify expensive sub-trees without executing the
40/// query.
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
42pub struct QueryPlan {
43    /// Root operator of the physical plan tree.
44    pub root: PlanNode,
45    /// Aggregate estimated cost for the entire plan (abstract cost units).
46    pub estimated_cost: f64,
47    /// Estimated number of result rows produced by the root operator.
48    pub estimated_cardinality: u64,
49}
50
51/// A single physical operator node in the query plan tree.
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
53#[serde(tag = "type", rename_all = "snake_case")]
54pub enum PlanNode {
55    /// Sequential / index scan over a triple pattern.
56    TripleScan {
57        /// Human-readable representation of the triple pattern.
58        pattern: String,
59        /// Which triple-store index was selected for this scan.
60        index_used: IndexType,
61        /// Estimated number of matching triples.
62        estimated_rows: u64,
63    },
64    /// Classic hash-join: build a hash-table from the smaller side, probe with
65    /// the larger side.
66    HashJoin {
67        left: Box<PlanNode>,
68        right: Box<PlanNode>,
69        /// Shared variables used as the join key.
70        join_vars: Vec<String>,
71    },
72    /// Nested-loop join; preferred when the inner side is very small or
73    /// already indexed.
74    NestedLoopJoin {
75        outer: Box<PlanNode>,
76        inner: Box<PlanNode>,
77    },
78    /// SPARQL FILTER expression applied on top of a child operator.
79    Filter {
80        /// String representation of the filter expression.
81        expr: String,
82        child: Box<PlanNode>,
83    },
84    /// ORDER BY clause.
85    Sort {
86        /// Ordered list of sort keys (prefixed with `+` / `-`).
87        vars: Vec<String>,
88        child: Box<PlanNode>,
89    },
90    /// LIMIT / OFFSET clause.
91    Limit {
92        limit: usize,
93        offset: usize,
94        child: Box<PlanNode>,
95    },
96    /// DISTINCT post-processing.
97    Distinct { child: Box<PlanNode> },
98    /// SPARQL UNION: evaluate both branches and concatenate results.
99    Union {
100        left: Box<PlanNode>,
101        right: Box<PlanNode>,
102    },
103    /// OPTIONAL (left outer join).
104    Optional {
105        left: Box<PlanNode>,
106        right: Box<PlanNode>,
107    },
108    /// GROUP BY / aggregate evaluation.
109    Aggregate {
110        /// Grouping key variables.
111        group_by: Vec<String>,
112        /// Aggregate expressions (e.g. `"COUNT(?x) AS ?count"`).
113        aggs: Vec<String>,
114        child: Box<PlanNode>,
115    },
116    /// A full sub-query (SELECT inside SELECT).
117    Subquery { plan: Box<QueryPlan> },
118    /// A SPARQL 1.1 property path evaluation.
119    PropertyPath {
120        subject: String,
121        path: String,
122        object: String,
123    },
124    /// Federated SERVICE clause: the sub-plan is evaluated on a remote endpoint.
125    Service {
126        endpoint: String,
127        subplan: Box<QueryPlan>,
128    },
129    /// Merge-join on a pre-sorted index.
130    MergeJoin {
131        left: Box<PlanNode>,
132        right: Box<PlanNode>,
133        join_vars: Vec<String>,
134    },
135    /// Pre-computed VALUES clause materialised as a table scan.
136    ValuesScan { vars: Vec<String>, row_count: usize },
137    /// GRAPH clause – scoped to a named graph.
138    NamedGraph { graph: String, child: Box<PlanNode> },
139}
140
141/// Which physical triple-store index was chosen for a [`PlanNode::TripleScan`].
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
143#[serde(rename_all = "UPPERCASE")]
144pub enum IndexType {
145    /// Subject → Predicate → Object (fastest for subject-bound patterns).
146    Spo,
147    /// Predicate → Object → Subject (fast for predicate / object lookups).
148    Pos,
149    /// Object → Subject → Predicate (fast when only object is bound).
150    Osp,
151    /// No useful index; a full store scan will be performed.
152    FullScan,
153}
154
155impl fmt::Display for IndexType {
156    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157        match self {
158            Self::Spo => write!(f, "SPO"),
159            Self::Pos => write!(f, "POS"),
160            Self::Osp => write!(f, "OSP"),
161            Self::FullScan => write!(f, "FULL_SCAN"),
162        }
163    }
164}
165
166/// Output format requested from [`QueryExplainer`].
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
168pub enum ExplainFormat {
169    /// Indented text tree, easy to read in a terminal.
170    Text,
171    /// Serialised JSON object – suitable for tooling and APIs.
172    Json,
173    /// Graphviz DOT language – pipe into `dot -Tpng` to produce a diagram.
174    Dot,
175}
176
177/// Renders a [`QueryPlan`] in the requested [`ExplainFormat`].
178///
179/// ```rust
180/// use oxirs_arq::explain::{QueryExplainer, ExplainFormat, QueryPlan, PlanNode, IndexType};
181///
182/// let plan = QueryPlan {
183///     root: PlanNode::TripleScan {
184///         pattern: "?s ?p ?o".into(),
185///         index_used: IndexType::FullScan,
186///         estimated_rows: 100_000,
187///     },
188///     estimated_cost: 12.5,
189///     estimated_cardinality: 100_000,
190/// };
191/// let exp = QueryExplainer::builder().show_estimates(true).build();
192/// let out = exp.explain_with_format(&plan, ExplainFormat::Json);
193/// assert!(out.contains("\"type\""));
194/// ```
195#[derive(Debug, Clone)]
196pub struct QueryExplainer {
197    show_estimates: bool,
198    show_costs: bool,
199    format: ExplainFormat,
200}
201
202impl Default for QueryExplainer {
203    fn default() -> Self {
204        Self {
205            show_estimates: true,
206            show_costs: true,
207            format: ExplainFormat::Text,
208        }
209    }
210}
211
212impl QueryExplainer {
213    /// Create a new explainer with default settings (text format, all annotations shown).
214    pub fn new() -> Self {
215        Self::default()
216    }
217
218    /// Return a builder for fine-grained configuration.
219    pub fn builder() -> QueryExplainerBuilder {
220        QueryExplainerBuilder::default()
221    }
222
223    /// Render the plan using the format configured at construction time.
224    pub fn explain(&self, plan: &QueryPlan) -> String {
225        match self.format {
226            ExplainFormat::Text => self.explain_text(plan),
227            ExplainFormat::Json => self.explain_json(plan),
228            ExplainFormat::Dot => self.explain_dot(plan),
229        }
230    }
231
232    /// Render the plan in a specific format, overriding the configured default.
233    pub fn explain_with_format(&self, plan: &QueryPlan, format: ExplainFormat) -> String {
234        match format {
235            ExplainFormat::Text => self.explain_text(plan),
236            ExplainFormat::Json => self.explain_json(plan),
237            ExplainFormat::Dot => self.explain_dot(plan),
238        }
239    }
240
241    // ── Text format ───────────────────────────────────────────────────────────
242
243    /// Render as an indented text tree.
244    pub fn explain_text(&self, plan: &QueryPlan) -> String {
245        let mut out = String::new();
246        out.push_str("Query Plan\n");
247        out.push_str("==========\n");
248        if self.show_costs {
249            out.push_str(&format!(
250                "Estimated cost        : {:.4}\n",
251                plan.estimated_cost
252            ));
253        }
254        if self.show_estimates {
255            out.push_str(&format!(
256                "Estimated cardinality : {}\n",
257                plan.estimated_cardinality
258            ));
259        }
260        out.push('\n');
261        self.format_node_text(&plan.root, &mut out, 0);
262        out
263    }
264
265    fn format_node_text(&self, node: &PlanNode, out: &mut String, depth: usize) {
266        let indent = "  ".repeat(depth);
267        let prefix = if depth == 0 {
268            String::new()
269        } else {
270            format!("{indent}└─ ")
271        };
272
273        match node {
274            PlanNode::TripleScan {
275                pattern,
276                index_used,
277                estimated_rows,
278            } => {
279                out.push_str(&format!("{prefix}TripleScan\n"));
280                let child_indent = "  ".repeat(depth + 1);
281                out.push_str(&format!("{child_indent}   pattern   : {pattern}\n"));
282                out.push_str(&format!("{child_indent}   index     : {index_used}\n"));
283                if self.show_estimates {
284                    out.push_str(&format!("{child_indent}   est. rows : {estimated_rows}\n"));
285                }
286            }
287            PlanNode::HashJoin {
288                left,
289                right,
290                join_vars,
291            } => {
292                out.push_str(&format!(
293                    "{prefix}HashJoin  [key: {}]\n",
294                    join_vars.join(", ")
295                ));
296                self.format_node_text(left, out, depth + 1);
297                self.format_node_text(right, out, depth + 1);
298            }
299            PlanNode::NestedLoopJoin { outer, inner } => {
300                out.push_str(&format!("{prefix}NestedLoopJoin\n"));
301                self.format_node_text(outer, out, depth + 1);
302                self.format_node_text(inner, out, depth + 1);
303            }
304            PlanNode::Filter { expr, child } => {
305                out.push_str(&format!("{prefix}Filter  [{expr}]\n"));
306                self.format_node_text(child, out, depth + 1);
307            }
308            PlanNode::Sort { vars, child } => {
309                out.push_str(&format!("{prefix}Sort  [{}]\n", vars.join(", ")));
310                self.format_node_text(child, out, depth + 1);
311            }
312            PlanNode::Limit {
313                limit,
314                offset,
315                child,
316            } => {
317                out.push_str(&format!("{prefix}Limit  [{limit} offset {offset}]\n"));
318                self.format_node_text(child, out, depth + 1);
319            }
320            PlanNode::Distinct { child } => {
321                out.push_str(&format!("{prefix}Distinct\n"));
322                self.format_node_text(child, out, depth + 1);
323            }
324            PlanNode::Union { left, right } => {
325                out.push_str(&format!("{prefix}Union\n"));
326                self.format_node_text(left, out, depth + 1);
327                self.format_node_text(right, out, depth + 1);
328            }
329            PlanNode::Optional { left, right } => {
330                out.push_str(&format!("{prefix}Optional\n"));
331                self.format_node_text(left, out, depth + 1);
332                self.format_node_text(right, out, depth + 1);
333            }
334            PlanNode::Aggregate {
335                group_by,
336                aggs,
337                child,
338            } => {
339                out.push_str(&format!(
340                    "{prefix}Aggregate  [group: {}]  [aggs: {}]\n",
341                    group_by.join(", "),
342                    aggs.join(", ")
343                ));
344                self.format_node_text(child, out, depth + 1);
345            }
346            PlanNode::Subquery { plan } => {
347                out.push_str(&format!("{prefix}Subquery\n"));
348                let child_indent = "  ".repeat(depth + 1);
349                if self.show_costs {
350                    out.push_str(&format!(
351                        "{child_indent}   est. cost : {:.4}\n",
352                        plan.estimated_cost
353                    ));
354                }
355                if self.show_estimates {
356                    out.push_str(&format!(
357                        "{child_indent}   est. card : {}\n",
358                        plan.estimated_cardinality
359                    ));
360                }
361                self.format_node_text(&plan.root, out, depth + 1);
362            }
363            PlanNode::PropertyPath {
364                subject,
365                path,
366                object,
367            } => {
368                out.push_str(&format!(
369                    "{prefix}PropertyPath  [{subject} {path} {object}]\n"
370                ));
371            }
372            PlanNode::Service { endpoint, subplan } => {
373                out.push_str(&format!("{prefix}Service  [{endpoint}]\n"));
374                let child_indent = "  ".repeat(depth + 1);
375                if self.show_costs {
376                    out.push_str(&format!(
377                        "{child_indent}   est. cost : {:.4}\n",
378                        subplan.estimated_cost
379                    ));
380                }
381                self.format_node_text(&subplan.root, out, depth + 1);
382            }
383            PlanNode::MergeJoin {
384                left,
385                right,
386                join_vars,
387            } => {
388                out.push_str(&format!(
389                    "{prefix}MergeJoin  [key: {}]\n",
390                    join_vars.join(", ")
391                ));
392                self.format_node_text(left, out, depth + 1);
393                self.format_node_text(right, out, depth + 1);
394            }
395            PlanNode::ValuesScan { vars, row_count } => {
396                out.push_str(&format!(
397                    "{prefix}ValuesScan  [vars: {}]  [{row_count} rows]\n",
398                    vars.join(", ")
399                ));
400            }
401            PlanNode::NamedGraph { graph, child } => {
402                out.push_str(&format!("{prefix}NamedGraph  [{graph}]\n"));
403                self.format_node_text(child, out, depth + 1);
404            }
405        }
406    }
407
408    // ── JSON format ───────────────────────────────────────────────────────────
409
410    /// Render as a JSON object.
411    pub fn explain_json(&self, plan: &QueryPlan) -> String {
412        match serde_json::to_string_pretty(plan) {
413            Ok(s) => s,
414            Err(e) => format!("{{\"error\": \"{e}\"}}"),
415        }
416    }
417
418    // ── DOT format ────────────────────────────────────────────────────────────
419
420    /// Render as Graphviz DOT language.
421    pub fn explain_dot(&self, plan: &QueryPlan) -> String {
422        let mut state = DotState::default();
423        let mut out = String::new();
424        out.push_str("digraph QueryPlan {\n");
425        out.push_str("  node [shape=box fontname=\"Helvetica\" fontsize=10];\n");
426        out.push_str("  rankdir=TB;\n");
427
428        // Root plan label node
429        let root_id = state.next_id();
430        if self.show_costs {
431            out.push_str(&format!(
432                "  {root_id} [label=\"QueryPlan\\ncost={:.4}\\ncard={}\"];\n",
433                plan.estimated_cost, plan.estimated_cardinality
434            ));
435        } else {
436            out.push_str(&format!("  {root_id} [label=\"QueryPlan\"];\n"));
437        }
438
439        let child_id = self.emit_dot_node(&plan.root, &mut state, &mut out);
440        out.push_str(&format!("  {root_id} -> {child_id};\n"));
441
442        out.push_str("}\n");
443        out
444    }
445
446    fn emit_dot_node(&self, node: &PlanNode, state: &mut DotState, out: &mut String) -> usize {
447        let id = state.next_id();
448        match node {
449            PlanNode::TripleScan {
450                pattern,
451                index_used,
452                estimated_rows,
453            } => {
454                let label = if self.show_estimates {
455                    format!("TripleScan\\n{pattern}\\nidx={index_used}\\nrows={estimated_rows}")
456                } else {
457                    format!("TripleScan\\n{pattern}\\nidx={index_used}")
458                };
459                out.push_str(&format!("  {id} [label=\"{label}\"];\n"));
460            }
461            PlanNode::HashJoin {
462                left,
463                right,
464                join_vars,
465            } => {
466                let vars = join_vars.join(",");
467                out.push_str(&format!("  {id} [label=\"HashJoin\\nkey={vars}\"];\n"));
468                let l = self.emit_dot_node(left, state, out);
469                let r = self.emit_dot_node(right, state, out);
470                out.push_str(&format!("  {id} -> {l} [label=\"left\"];\n"));
471                out.push_str(&format!("  {id} -> {r} [label=\"right\"];\n"));
472            }
473            PlanNode::NestedLoopJoin { outer, inner } => {
474                out.push_str(&format!("  {id} [label=\"NestedLoopJoin\"];\n"));
475                let o = self.emit_dot_node(outer, state, out);
476                let i = self.emit_dot_node(inner, state, out);
477                out.push_str(&format!("  {id} -> {o} [label=\"outer\"];\n"));
478                out.push_str(&format!("  {id} -> {i} [label=\"inner\"];\n"));
479            }
480            PlanNode::Filter { expr, child } => {
481                out.push_str(&format!("  {id} [label=\"Filter\\n{expr}\"];\n"));
482                let c = self.emit_dot_node(child, state, out);
483                out.push_str(&format!("  {id} -> {c};\n"));
484            }
485            PlanNode::Sort { vars, child } => {
486                let keys = vars.join(",");
487                out.push_str(&format!("  {id} [label=\"Sort\\n{keys}\"];\n"));
488                let c = self.emit_dot_node(child, state, out);
489                out.push_str(&format!("  {id} -> {c};\n"));
490            }
491            PlanNode::Limit {
492                limit,
493                offset,
494                child,
495            } => {
496                out.push_str(&format!(
497                    "  {id} [label=\"Limit {limit}\\noffset {offset}\"];\n"
498                ));
499                let c = self.emit_dot_node(child, state, out);
500                out.push_str(&format!("  {id} -> {c};\n"));
501            }
502            PlanNode::Distinct { child } => {
503                out.push_str(&format!("  {id} [label=\"Distinct\"];\n"));
504                let c = self.emit_dot_node(child, state, out);
505                out.push_str(&format!("  {id} -> {c};\n"));
506            }
507            PlanNode::Union { left, right } => {
508                out.push_str(&format!("  {id} [label=\"Union\"];\n"));
509                let l = self.emit_dot_node(left, state, out);
510                let r = self.emit_dot_node(right, state, out);
511                out.push_str(&format!("  {id} -> {l} [label=\"left\"];\n"));
512                out.push_str(&format!("  {id} -> {r} [label=\"right\"];\n"));
513            }
514            PlanNode::Optional { left, right } => {
515                out.push_str(&format!("  {id} [label=\"Optional\"];\n"));
516                let l = self.emit_dot_node(left, state, out);
517                let r = self.emit_dot_node(right, state, out);
518                out.push_str(&format!("  {id} -> {l} [label=\"left\"];\n"));
519                out.push_str(&format!("  {id} -> {r} [label=\"right\"];\n"));
520            }
521            PlanNode::Aggregate {
522                group_by,
523                aggs,
524                child,
525            } => {
526                let gb = group_by.join(",");
527                let ag = aggs.join(",");
528                out.push_str(&format!(
529                    "  {id} [label=\"Aggregate\\ngroup={gb}\\naggs={ag}\"];\n"
530                ));
531                let c = self.emit_dot_node(child, state, out);
532                out.push_str(&format!("  {id} -> {c};\n"));
533            }
534            PlanNode::Subquery { plan } => {
535                out.push_str(&format!("  {id} [label=\"Subquery\"];\n"));
536                let c = self.emit_dot_node(&plan.root, state, out);
537                out.push_str(&format!("  {id} -> {c};\n"));
538            }
539            PlanNode::PropertyPath {
540                subject,
541                path,
542                object,
543            } => {
544                out.push_str(&format!(
545                    "  {id} [label=\"PropertyPath\\n{subject} {path} {object}\"];\n"
546                ));
547            }
548            PlanNode::Service { endpoint, subplan } => {
549                out.push_str(&format!("  {id} [label=\"Service\\n{endpoint}\"];\n"));
550                let c = self.emit_dot_node(&subplan.root, state, out);
551                out.push_str(&format!("  {id} -> {c};\n"));
552            }
553            PlanNode::MergeJoin {
554                left,
555                right,
556                join_vars,
557            } => {
558                let vars = join_vars.join(",");
559                out.push_str(&format!("  {id} [label=\"MergeJoin\\nkey={vars}\"];\n"));
560                let l = self.emit_dot_node(left, state, out);
561                let r = self.emit_dot_node(right, state, out);
562                out.push_str(&format!("  {id} -> {l} [label=\"left\"];\n"));
563                out.push_str(&format!("  {id} -> {r} [label=\"right\"];\n"));
564            }
565            PlanNode::ValuesScan { vars, row_count } => {
566                let v = vars.join(",");
567                out.push_str(&format!(
568                    "  {id} [label=\"ValuesScan\\nvars={v}\\nrows={row_count}\"];\n"
569                ));
570            }
571            PlanNode::NamedGraph { graph, child } => {
572                out.push_str(&format!("  {id} [label=\"NamedGraph\\n{graph}\"];\n"));
573                let c = self.emit_dot_node(child, state, out);
574                out.push_str(&format!("  {id} -> {c};\n"));
575            }
576        }
577        id
578    }
579}
580
581// ── Builder ───────────────────────────────────────────────────────────────────
582
583/// Builder for [`QueryExplainer`].
584#[derive(Debug, Clone, Default)]
585pub struct QueryExplainerBuilder {
586    show_estimates: Option<bool>,
587    show_costs: Option<bool>,
588    format: Option<ExplainFormat>,
589}
590
591impl QueryExplainerBuilder {
592    /// Whether to include estimated row counts in the output.
593    pub fn show_estimates(mut self, val: bool) -> Self {
594        self.show_estimates = Some(val);
595        self
596    }
597
598    /// Whether to include estimated cost values in the output.
599    pub fn show_costs(mut self, val: bool) -> Self {
600        self.show_costs = Some(val);
601        self
602    }
603
604    /// Set the default output format.
605    pub fn format(mut self, fmt: ExplainFormat) -> Self {
606        self.format = Some(fmt);
607        self
608    }
609
610    /// Build the [`QueryExplainer`].
611    pub fn build(self) -> QueryExplainer {
612        QueryExplainer {
613            show_estimates: self.show_estimates.unwrap_or(true),
614            show_costs: self.show_costs.unwrap_or(true),
615            format: self.format.unwrap_or(ExplainFormat::Text),
616        }
617    }
618}
619
620// ── Internal helpers ──────────────────────────────────────────────────────────
621
622#[derive(Debug, Default)]
623struct DotState {
624    counter: usize,
625}
626
627impl DotState {
628    fn next_id(&mut self) -> usize {
629        self.counter += 1;
630        self.counter
631    }
632}
633
634// ── Convenience constructors on PlanNode ──────────────────────────────────────
635
636impl PlanNode {
637    /// Build a `TripleScan` node.
638    pub fn triple_scan(pattern: impl Into<String>, index: IndexType, rows: u64) -> Self {
639        Self::TripleScan {
640            pattern: pattern.into(),
641            index_used: index,
642            estimated_rows: rows,
643        }
644    }
645
646    /// Build a `HashJoin` node.
647    pub fn hash_join(left: PlanNode, right: PlanNode, vars: Vec<String>) -> Self {
648        Self::HashJoin {
649            left: Box::new(left),
650            right: Box::new(right),
651            join_vars: vars,
652        }
653    }
654
655    /// Build a `Filter` node.
656    pub fn filter(expr: impl Into<String>, child: PlanNode) -> Self {
657        Self::Filter {
658            expr: expr.into(),
659            child: Box::new(child),
660        }
661    }
662
663    /// Build a `Sort` node.
664    pub fn sort(vars: Vec<String>, child: PlanNode) -> Self {
665        Self::Sort {
666            vars,
667            child: Box::new(child),
668        }
669    }
670
671    /// Build a `Limit` node.
672    pub fn limit(limit: usize, offset: usize, child: PlanNode) -> Self {
673        Self::Limit {
674            limit,
675            offset,
676            child: Box::new(child),
677        }
678    }
679
680    /// Build a `Distinct` node.
681    pub fn distinct(child: PlanNode) -> Self {
682        Self::Distinct {
683            child: Box::new(child),
684        }
685    }
686
687    /// Build a `Union` node.
688    pub fn union(left: PlanNode, right: PlanNode) -> Self {
689        Self::Union {
690            left: Box::new(left),
691            right: Box::new(right),
692        }
693    }
694
695    /// Build an `Optional` node.
696    pub fn optional(left: PlanNode, right: PlanNode) -> Self {
697        Self::Optional {
698            left: Box::new(left),
699            right: Box::new(right),
700        }
701    }
702
703    /// Count the total number of nodes in the plan tree rooted at this node.
704    pub fn node_count(&self) -> usize {
705        match self {
706            Self::TripleScan { .. } | Self::PropertyPath { .. } | Self::ValuesScan { .. } => 1,
707            Self::Distinct { child }
708            | Self::Filter { child, .. }
709            | Self::Sort { child, .. }
710            | Self::Limit { child, .. }
711            | Self::NamedGraph { child, .. } => 1 + child.node_count(),
712            Self::HashJoin { left, right, .. }
713            | Self::NestedLoopJoin {
714                outer: left,
715                inner: right,
716            }
717            | Self::Union { left, right }
718            | Self::Optional { left, right }
719            | Self::MergeJoin { left, right, .. } => 1 + left.node_count() + right.node_count(),
720            Self::Aggregate { child, .. } => 1 + child.node_count(),
721            Self::Subquery { plan } => 1 + plan.root.node_count(),
722            Self::Service { subplan, .. } => 1 + subplan.root.node_count(),
723        }
724    }
725
726    /// Maximum depth of the plan tree rooted at this node (0 = leaf).
727    pub fn depth(&self) -> usize {
728        match self {
729            Self::TripleScan { .. } | Self::PropertyPath { .. } | Self::ValuesScan { .. } => 0,
730            Self::Distinct { child }
731            | Self::Filter { child, .. }
732            | Self::Sort { child, .. }
733            | Self::Limit { child, .. }
734            | Self::NamedGraph { child, .. }
735            | Self::Aggregate { child, .. } => 1 + child.depth(),
736            Self::HashJoin { left, right, .. }
737            | Self::NestedLoopJoin {
738                outer: left,
739                inner: right,
740            }
741            | Self::Union { left, right }
742            | Self::Optional { left, right }
743            | Self::MergeJoin { left, right, .. } => 1 + left.depth().max(right.depth()),
744            Self::Subquery { plan } => 1 + plan.root.depth(),
745            Self::Service { subplan, .. } => 1 + subplan.root.depth(),
746        }
747    }
748}
749
750// ── Tests ─────────────────────────────────────────────────────────────────────
751
752#[cfg(test)]
753mod tests {
754    use super::*;
755
756    fn make_scan(pattern: &str, index: IndexType, rows: u64) -> PlanNode {
757        PlanNode::triple_scan(pattern, index, rows)
758    }
759
760    fn make_plan(root: PlanNode) -> QueryPlan {
761        let cardinality = 100;
762        QueryPlan {
763            estimated_cost: 5.0,
764            estimated_cardinality: cardinality,
765            root,
766        }
767    }
768
769    // ── Basic construction ────────────────────────────────────────────────────
770
771    #[test]
772    fn test_triple_scan_construction() {
773        let node = make_scan("?s rdf:type ?t", IndexType::Spo, 500);
774        if let PlanNode::TripleScan {
775            pattern,
776            index_used,
777            estimated_rows,
778        } = &node
779        {
780            assert_eq!(pattern, "?s rdf:type ?t");
781            assert_eq!(*index_used, IndexType::Spo);
782            assert_eq!(*estimated_rows, 500);
783        } else {
784            panic!("expected TripleScan");
785        }
786    }
787
788    #[test]
789    fn test_hash_join_construction() {
790        let left = make_scan("?s ?p ?o", IndexType::FullScan, 1000);
791        let right = make_scan("?s foaf:name ?n", IndexType::Spo, 50);
792        let join = PlanNode::hash_join(left, right, vec!["?s".to_string()]);
793        assert!(matches!(join, PlanNode::HashJoin { .. }));
794    }
795
796    #[test]
797    fn test_nested_loop_join_construction() {
798        let outer = make_scan("?s a ?t", IndexType::Pos, 10);
799        let inner = make_scan("?s ?p ?o", IndexType::Spo, 5);
800        let node = PlanNode::NestedLoopJoin {
801            outer: Box::new(outer),
802            inner: Box::new(inner),
803        };
804        assert!(matches!(node, PlanNode::NestedLoopJoin { .. }));
805    }
806
807    #[test]
808    fn test_filter_construction() {
809        let scan = make_scan("?s ?p ?o", IndexType::FullScan, 100);
810        let node = PlanNode::filter("?o > 5", scan);
811        if let PlanNode::Filter { expr, .. } = &node {
812            assert_eq!(expr, "?o > 5");
813        } else {
814            panic!("expected Filter");
815        }
816    }
817
818    #[test]
819    fn test_sort_construction() {
820        let scan = make_scan("?s ?p ?o", IndexType::FullScan, 100);
821        let node = PlanNode::sort(vec!["+?s".into(), "-?o".into()], scan);
822        if let PlanNode::Sort { vars, .. } = &node {
823            assert_eq!(vars, &["+?s", "-?o"]);
824        } else {
825            panic!("expected Sort");
826        }
827    }
828
829    #[test]
830    fn test_limit_construction() {
831        let scan = make_scan("?s ?p ?o", IndexType::FullScan, 100);
832        let node = PlanNode::limit(10, 20, scan);
833        if let PlanNode::Limit { limit, offset, .. } = &node {
834            assert_eq!(*limit, 10);
835            assert_eq!(*offset, 20);
836        } else {
837            panic!("expected Limit");
838        }
839    }
840
841    #[test]
842    fn test_distinct_construction() {
843        let scan = make_scan("?s ?p ?o", IndexType::FullScan, 100);
844        let node = PlanNode::distinct(scan);
845        assert!(matches!(node, PlanNode::Distinct { .. }));
846    }
847
848    #[test]
849    fn test_union_construction() {
850        let left = make_scan("?s a owl:Class", IndexType::Pos, 30);
851        let right = make_scan("?s a rdfs:Class", IndexType::Pos, 10);
852        let node = PlanNode::union(left, right);
853        assert!(matches!(node, PlanNode::Union { .. }));
854    }
855
856    #[test]
857    fn test_optional_construction() {
858        let main = make_scan("?s foaf:name ?n", IndexType::Spo, 200);
859        let opt = make_scan("?s foaf:mbox ?m", IndexType::Spo, 80);
860        let node = PlanNode::optional(main, opt);
861        assert!(matches!(node, PlanNode::Optional { .. }));
862    }
863
864    #[test]
865    fn test_aggregate_construction() {
866        let scan = make_scan("?s ?p ?o", IndexType::FullScan, 1000);
867        let node = PlanNode::Aggregate {
868            group_by: vec!["?s".into()],
869            aggs: vec!["COUNT(?o) AS ?cnt".into()],
870            child: Box::new(scan),
871        };
872        if let PlanNode::Aggregate { aggs, group_by, .. } = &node {
873            assert_eq!(group_by[0], "?s");
874            assert!(aggs[0].contains("COUNT"));
875        } else {
876            panic!("expected Aggregate");
877        }
878    }
879
880    #[test]
881    fn test_subquery_construction() {
882        let inner_plan = make_plan(make_scan("?x ?y ?z", IndexType::FullScan, 5));
883        let node = PlanNode::Subquery {
884            plan: Box::new(inner_plan),
885        };
886        assert!(matches!(node, PlanNode::Subquery { .. }));
887    }
888
889    #[test]
890    fn test_property_path_construction() {
891        let node = PlanNode::PropertyPath {
892            subject: "?s".into(),
893            path: "foaf:knows+".into(),
894            object: "?o".into(),
895        };
896        if let PlanNode::PropertyPath { path, .. } = &node {
897            assert!(path.contains("foaf"));
898        } else {
899            panic!("expected PropertyPath");
900        }
901    }
902
903    #[test]
904    fn test_service_construction() {
905        let sub = make_plan(make_scan("?s ?p ?o", IndexType::FullScan, 50));
906        let node = PlanNode::Service {
907            endpoint: "http://remote.example.org/sparql".into(),
908            subplan: Box::new(sub),
909        };
910        if let PlanNode::Service { endpoint, .. } = &node {
911            assert!(endpoint.contains("remote"));
912        } else {
913            panic!("expected Service");
914        }
915    }
916
917    #[test]
918    fn test_merge_join_construction() {
919        let left = make_scan("?s ?p ?o", IndexType::Spo, 500);
920        let right = make_scan("?s a ?t", IndexType::Pos, 100);
921        let node = PlanNode::MergeJoin {
922            left: Box::new(left),
923            right: Box::new(right),
924            join_vars: vec!["?s".into()],
925        };
926        assert!(matches!(node, PlanNode::MergeJoin { .. }));
927    }
928
929    #[test]
930    fn test_values_scan_construction() {
931        let node = PlanNode::ValuesScan {
932            vars: vec!["?s".into(), "?p".into()],
933            row_count: 3,
934        };
935        if let PlanNode::ValuesScan { row_count, .. } = &node {
936            assert_eq!(*row_count, 3);
937        } else {
938            panic!("expected ValuesScan");
939        }
940    }
941
942    #[test]
943    fn test_named_graph_construction() {
944        let scan = make_scan("?s ?p ?o", IndexType::FullScan, 100);
945        let node = PlanNode::NamedGraph {
946            graph: "http://example.org/g1".into(),
947            child: Box::new(scan),
948        };
949        if let PlanNode::NamedGraph { graph, .. } = &node {
950            assert!(graph.contains("g1"));
951        } else {
952            panic!("expected NamedGraph");
953        }
954    }
955
956    // ── Node count / depth ────────────────────────────────────────────────────
957
958    #[test]
959    fn test_node_count_leaf() {
960        let node = make_scan("?s ?p ?o", IndexType::Spo, 0);
961        assert_eq!(node.node_count(), 1);
962    }
963
964    #[test]
965    fn test_node_count_nested() {
966        let left = make_scan("?s a ?t", IndexType::Pos, 10);
967        let right = make_scan("?s ?p ?o", IndexType::Spo, 5);
968        let join = PlanNode::hash_join(left, right, vec![]);
969        assert_eq!(join.node_count(), 3);
970    }
971
972    #[test]
973    fn test_node_count_deep() {
974        let scan = make_scan("?s ?p ?o", IndexType::FullScan, 100);
975        let filter = PlanNode::filter("?o > 0", scan);
976        let sort = PlanNode::sort(vec!["?s".into()], filter);
977        let limit = PlanNode::limit(10, 0, sort);
978        assert_eq!(limit.node_count(), 4);
979    }
980
981    #[test]
982    fn test_depth_leaf() {
983        let node = make_scan("?s ?p ?o", IndexType::Spo, 0);
984        assert_eq!(node.depth(), 0);
985    }
986
987    #[test]
988    fn test_depth_chain() {
989        let scan = make_scan("?s ?p ?o", IndexType::FullScan, 10);
990        let filter = PlanNode::filter("true", scan);
991        let sort = PlanNode::sort(vec![], filter);
992        assert_eq!(sort.depth(), 2);
993    }
994
995    #[test]
996    fn test_depth_join() {
997        let left = make_scan("?s a ?t", IndexType::Pos, 10);
998        let right = make_scan("?s ?p ?o", IndexType::Spo, 5);
999        let join = PlanNode::hash_join(left, right, vec![]);
1000        assert_eq!(join.depth(), 1);
1001    }
1002
1003    // ── Index type display ────────────────────────────────────────────────────
1004
1005    #[test]
1006    fn test_index_type_display_spo() {
1007        assert_eq!(IndexType::Spo.to_string(), "SPO");
1008    }
1009
1010    #[test]
1011    fn test_index_type_display_pos() {
1012        assert_eq!(IndexType::Pos.to_string(), "POS");
1013    }
1014
1015    #[test]
1016    fn test_index_type_display_osp() {
1017        assert_eq!(IndexType::Osp.to_string(), "OSP");
1018    }
1019
1020    #[test]
1021    fn test_index_type_display_fullscan() {
1022        assert_eq!(IndexType::FullScan.to_string(), "FULL_SCAN");
1023    }
1024
1025    // ── Text format ───────────────────────────────────────────────────────────
1026
1027    #[test]
1028    fn test_explain_text_contains_header() {
1029        let plan = make_plan(make_scan("?s ?p ?o", IndexType::Spo, 10));
1030        let out = QueryExplainer::new().explain_text(&plan);
1031        assert!(out.contains("Query Plan"));
1032        assert!(out.contains("=========="));
1033    }
1034
1035    #[test]
1036    fn test_explain_text_contains_cost() {
1037        let plan = make_plan(make_scan("?s ?p ?o", IndexType::Spo, 10));
1038        let out = QueryExplainer::new().explain_text(&plan);
1039        assert!(out.contains("5.0000"));
1040    }
1041
1042    #[test]
1043    fn test_explain_text_contains_cardinality() {
1044        let plan = make_plan(make_scan("?s ?p ?o", IndexType::Spo, 10));
1045        let out = QueryExplainer::new().explain_text(&plan);
1046        assert!(out.contains("100"));
1047    }
1048
1049    #[test]
1050    fn test_explain_text_triple_scan() {
1051        let plan = make_plan(make_scan("?s rdf:type owl:Class", IndexType::Pos, 42));
1052        let out = QueryExplainer::new().explain_text(&plan);
1053        assert!(out.contains("TripleScan"));
1054        assert!(out.contains("owl:Class"));
1055        assert!(out.contains("POS"));
1056        assert!(out.contains("42"));
1057    }
1058
1059    #[test]
1060    fn test_explain_text_hash_join() {
1061        let left = make_scan("?s a ?t", IndexType::Pos, 10);
1062        let right = make_scan("?s ?p ?o", IndexType::Spo, 5);
1063        let join = PlanNode::hash_join(left, right, vec!["?s".into()]);
1064        let plan = make_plan(join);
1065        let out = QueryExplainer::new().explain_text(&plan);
1066        assert!(out.contains("HashJoin"));
1067        assert!(out.contains("?s"));
1068    }
1069
1070    #[test]
1071    fn test_explain_text_filter() {
1072        let scan = make_scan("?s ?p ?o", IndexType::FullScan, 100);
1073        let filtered = PlanNode::filter("?o > 10", scan);
1074        let plan = make_plan(filtered);
1075        let out = QueryExplainer::new().explain_text(&plan);
1076        assert!(out.contains("Filter"));
1077        assert!(out.contains("?o > 10"));
1078    }
1079
1080    #[test]
1081    fn test_explain_text_sort() {
1082        let scan = make_scan("?s ?p ?o", IndexType::FullScan, 100);
1083        let sorted = PlanNode::sort(vec!["+?s".into()], scan);
1084        let plan = make_plan(sorted);
1085        let out = QueryExplainer::new().explain_text(&plan);
1086        assert!(out.contains("Sort"));
1087        assert!(out.contains("+?s"));
1088    }
1089
1090    #[test]
1091    fn test_explain_text_limit() {
1092        let scan = make_scan("?s ?p ?o", IndexType::FullScan, 100);
1093        let limited = PlanNode::limit(25, 0, scan);
1094        let plan = make_plan(limited);
1095        let out = QueryExplainer::new().explain_text(&plan);
1096        assert!(out.contains("Limit"));
1097        assert!(out.contains("25"));
1098    }
1099
1100    #[test]
1101    fn test_explain_text_distinct() {
1102        let scan = make_scan("?s ?p ?o", IndexType::FullScan, 100);
1103        let node = PlanNode::distinct(scan);
1104        let plan = make_plan(node);
1105        let out = QueryExplainer::new().explain_text(&plan);
1106        assert!(out.contains("Distinct"));
1107    }
1108
1109    #[test]
1110    fn test_explain_text_union() {
1111        let left = make_scan("?s a owl:Class", IndexType::Pos, 30);
1112        let right = make_scan("?s a rdfs:Class", IndexType::Pos, 10);
1113        let node = PlanNode::union(left, right);
1114        let plan = make_plan(node);
1115        let out = QueryExplainer::new().explain_text(&plan);
1116        assert!(out.contains("Union"));
1117    }
1118
1119    #[test]
1120    fn test_explain_text_optional() {
1121        let main = make_scan("?s foaf:name ?n", IndexType::Spo, 200);
1122        let opt = make_scan("?s foaf:mbox ?m", IndexType::Spo, 80);
1123        let node = PlanNode::optional(main, opt);
1124        let plan = make_plan(node);
1125        let out = QueryExplainer::new().explain_text(&plan);
1126        assert!(out.contains("Optional"));
1127    }
1128
1129    #[test]
1130    fn test_explain_text_aggregate() {
1131        let scan = make_scan("?s ?p ?o", IndexType::FullScan, 1000);
1132        let node = PlanNode::Aggregate {
1133            group_by: vec!["?s".into()],
1134            aggs: vec!["COUNT(?o) AS ?cnt".into()],
1135            child: Box::new(scan),
1136        };
1137        let plan = make_plan(node);
1138        let out = QueryExplainer::new().explain_text(&plan);
1139        assert!(out.contains("Aggregate"));
1140        assert!(out.contains("COUNT"));
1141    }
1142
1143    #[test]
1144    fn test_explain_text_subquery() {
1145        let inner = make_plan(make_scan("?x ?y ?z", IndexType::FullScan, 5));
1146        let node = PlanNode::Subquery {
1147            plan: Box::new(inner),
1148        };
1149        let plan = make_plan(node);
1150        let out = QueryExplainer::new().explain_text(&plan);
1151        assert!(out.contains("Subquery"));
1152    }
1153
1154    #[test]
1155    fn test_explain_text_property_path() {
1156        let node = PlanNode::PropertyPath {
1157            subject: "?s".into(),
1158            path: "foaf:knows+".into(),
1159            object: "?o".into(),
1160        };
1161        let plan = make_plan(node);
1162        let out = QueryExplainer::new().explain_text(&plan);
1163        assert!(out.contains("PropertyPath"));
1164        assert!(out.contains("foaf:knows+"));
1165    }
1166
1167    #[test]
1168    fn test_explain_text_service() {
1169        let sub = make_plan(make_scan("?s ?p ?o", IndexType::FullScan, 50));
1170        let node = PlanNode::Service {
1171            endpoint: "http://remote.example.org/sparql".into(),
1172            subplan: Box::new(sub),
1173        };
1174        let plan = make_plan(node);
1175        let out = QueryExplainer::new().explain_text(&plan);
1176        assert!(out.contains("Service"));
1177        assert!(out.contains("remote.example.org"));
1178    }
1179
1180    #[test]
1181    fn test_explain_text_merge_join() {
1182        let left = make_scan("?s ?p ?o", IndexType::Spo, 500);
1183        let right = make_scan("?s a ?t", IndexType::Pos, 100);
1184        let node = PlanNode::MergeJoin {
1185            left: Box::new(left),
1186            right: Box::new(right),
1187            join_vars: vec!["?s".into()],
1188        };
1189        let plan = make_plan(node);
1190        let out = QueryExplainer::new().explain_text(&plan);
1191        assert!(out.contains("MergeJoin"));
1192    }
1193
1194    #[test]
1195    fn test_explain_text_values_scan() {
1196        let node = PlanNode::ValuesScan {
1197            vars: vec!["?s".into()],
1198            row_count: 7,
1199        };
1200        let plan = make_plan(node);
1201        let out = QueryExplainer::new().explain_text(&plan);
1202        assert!(out.contains("ValuesScan"));
1203        assert!(out.contains("7"));
1204    }
1205
1206    #[test]
1207    fn test_explain_text_named_graph() {
1208        let scan = make_scan("?s ?p ?o", IndexType::FullScan, 50);
1209        let node = PlanNode::NamedGraph {
1210            graph: "http://example.org/g1".into(),
1211            child: Box::new(scan),
1212        };
1213        let plan = make_plan(node);
1214        let out = QueryExplainer::new().explain_text(&plan);
1215        assert!(out.contains("NamedGraph"));
1216        assert!(out.contains("g1"));
1217    }
1218
1219    // ── JSON format ───────────────────────────────────────────────────────────
1220
1221    #[test]
1222    fn test_explain_json_is_valid() {
1223        let plan = make_plan(make_scan("?s ?p ?o", IndexType::Spo, 10));
1224        let out = QueryExplainer::new().explain_json(&plan);
1225        let parsed: serde_json::Value = serde_json::from_str(&out).expect("invalid JSON");
1226        assert!(parsed.is_object());
1227    }
1228
1229    #[test]
1230    fn test_explain_json_contains_type_field() {
1231        let plan = make_plan(make_scan("?s ?p ?o", IndexType::Spo, 10));
1232        let out = QueryExplainer::new().explain_json(&plan);
1233        assert!(out.contains("\"type\""));
1234    }
1235
1236    #[test]
1237    fn test_explain_json_triple_scan_fields() {
1238        let plan = make_plan(make_scan("?s rdf:type owl:Class", IndexType::Pos, 42));
1239        let out = QueryExplainer::new().explain_json(&plan);
1240        assert!(out.contains("triple_scan"));
1241        assert!(out.contains("owl:Class"));
1242        assert!(out.contains("POS"));
1243        assert!(out.contains("42"));
1244    }
1245
1246    #[test]
1247    fn test_explain_json_hash_join() {
1248        let left = make_scan("?s a ?t", IndexType::Pos, 10);
1249        let right = make_scan("?s ?p ?o", IndexType::Spo, 5);
1250        let join = PlanNode::hash_join(left, right, vec!["?s".into()]);
1251        let plan = make_plan(join);
1252        let out = QueryExplainer::new().explain_json(&plan);
1253        assert!(out.contains("hash_join"));
1254        assert!(out.contains("join_vars"));
1255    }
1256
1257    #[test]
1258    fn test_explain_json_filter() {
1259        let scan = make_scan("?s ?p ?o", IndexType::FullScan, 100);
1260        let node = PlanNode::filter("?o > 10", scan);
1261        let plan = make_plan(node);
1262        let out = QueryExplainer::new().explain_json(&plan);
1263        assert!(out.contains("filter"));
1264        assert!(out.contains("expr"));
1265    }
1266
1267    #[test]
1268    fn test_explain_json_estimated_cost() {
1269        let plan = make_plan(make_scan("?s ?p ?o", IndexType::Spo, 10));
1270        let out = QueryExplainer::new().explain_json(&plan);
1271        assert!(out.contains("estimated_cost"));
1272        assert!(out.contains("5.0"));
1273    }
1274
1275    #[test]
1276    fn test_explain_json_aggregate() {
1277        let scan = make_scan("?s ?p ?o", IndexType::FullScan, 1000);
1278        let node = PlanNode::Aggregate {
1279            group_by: vec!["?s".into()],
1280            aggs: vec!["SUM(?v) AS ?total".into()],
1281            child: Box::new(scan),
1282        };
1283        let plan = make_plan(node);
1284        let out = QueryExplainer::new().explain_json(&plan);
1285        assert!(out.contains("aggregate"));
1286        assert!(out.contains("group_by"));
1287        assert!(out.contains("aggs"));
1288    }
1289
1290    #[test]
1291    fn test_explain_json_union() {
1292        let left = make_scan("?s a owl:Class", IndexType::Pos, 30);
1293        let right = make_scan("?s a rdfs:Class", IndexType::Pos, 10);
1294        let node = PlanNode::union(left, right);
1295        let plan = make_plan(node);
1296        let out = QueryExplainer::new().explain_json(&plan);
1297        assert!(out.contains("union"));
1298    }
1299
1300    // ── DOT format ────────────────────────────────────────────────────────────
1301
1302    #[test]
1303    fn test_explain_dot_starts_with_digraph() {
1304        let plan = make_plan(make_scan("?s ?p ?o", IndexType::Spo, 10));
1305        let out = QueryExplainer::new().explain_dot(&plan);
1306        assert!(out.starts_with("digraph QueryPlan {"));
1307    }
1308
1309    #[test]
1310    fn test_explain_dot_ends_with_brace() {
1311        let plan = make_plan(make_scan("?s ?p ?o", IndexType::Spo, 10));
1312        let out = QueryExplainer::new().explain_dot(&plan);
1313        assert!(out.trim().ends_with('}'));
1314    }
1315
1316    #[test]
1317    fn test_explain_dot_contains_triple_scan() {
1318        let plan = make_plan(make_scan("?s a owl:Class", IndexType::Pos, 42));
1319        let out = QueryExplainer::new().explain_dot(&plan);
1320        assert!(out.contains("TripleScan"));
1321    }
1322
1323    #[test]
1324    fn test_explain_dot_contains_edge_arrows() {
1325        let left = make_scan("?s a ?t", IndexType::Pos, 10);
1326        let right = make_scan("?s ?p ?o", IndexType::Spo, 5);
1327        let join = PlanNode::hash_join(left, right, vec!["?s".into()]);
1328        let plan = make_plan(join);
1329        let out = QueryExplainer::new().explain_dot(&plan);
1330        assert!(out.contains("->"));
1331    }
1332
1333    #[test]
1334    fn test_explain_dot_hash_join_labels() {
1335        let left = make_scan("?s a ?t", IndexType::Pos, 10);
1336        let right = make_scan("?s ?p ?o", IndexType::Spo, 5);
1337        let join = PlanNode::hash_join(left, right, vec!["?s".into()]);
1338        let plan = make_plan(join);
1339        let out = QueryExplainer::new().explain_dot(&plan);
1340        assert!(out.contains("HashJoin"));
1341        assert!(out.contains("left"));
1342        assert!(out.contains("right"));
1343    }
1344
1345    #[test]
1346    fn test_explain_dot_filter() {
1347        let scan = make_scan("?s ?p ?o", IndexType::FullScan, 100);
1348        let node = PlanNode::filter("?o > 10", scan);
1349        let plan = make_plan(node);
1350        let out = QueryExplainer::new().explain_dot(&plan);
1351        assert!(out.contains("Filter"));
1352    }
1353
1354    #[test]
1355    fn test_explain_dot_union() {
1356        let left = make_scan("?s a owl:Class", IndexType::Pos, 30);
1357        let right = make_scan("?s a rdfs:Class", IndexType::Pos, 10);
1358        let node = PlanNode::union(left, right);
1359        let plan = make_plan(node);
1360        let out = QueryExplainer::new().explain_dot(&plan);
1361        assert!(out.contains("Union"));
1362    }
1363
1364    #[test]
1365    fn test_explain_dot_service() {
1366        let sub = make_plan(make_scan("?s ?p ?o", IndexType::FullScan, 50));
1367        let node = PlanNode::Service {
1368            endpoint: "http://remote.example.org/sparql".into(),
1369            subplan: Box::new(sub),
1370        };
1371        let plan = make_plan(node);
1372        let out = QueryExplainer::new().explain_dot(&plan);
1373        assert!(out.contains("Service"));
1374    }
1375
1376    // ── explain_with_format ───────────────────────────────────────────────────
1377
1378    #[test]
1379    fn test_explain_with_format_text() {
1380        let plan = make_plan(make_scan("?s ?p ?o", IndexType::Spo, 10));
1381        let exp = QueryExplainer::new();
1382        let out = exp.explain_with_format(&plan, ExplainFormat::Text);
1383        assert!(out.contains("Query Plan"));
1384    }
1385
1386    #[test]
1387    fn test_explain_with_format_json() {
1388        let plan = make_plan(make_scan("?s ?p ?o", IndexType::Spo, 10));
1389        let exp = QueryExplainer::new();
1390        let out = exp.explain_with_format(&plan, ExplainFormat::Json);
1391        let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
1392        assert!(v.is_object());
1393    }
1394
1395    #[test]
1396    fn test_explain_with_format_dot() {
1397        let plan = make_plan(make_scan("?s ?p ?o", IndexType::Spo, 10));
1398        let exp = QueryExplainer::new();
1399        let out = exp.explain_with_format(&plan, ExplainFormat::Dot);
1400        assert!(out.contains("digraph"));
1401    }
1402
1403    // ── Builder ───────────────────────────────────────────────────────────────
1404
1405    #[test]
1406    fn test_builder_default() {
1407        let exp = QueryExplainer::builder().build();
1408        assert!(exp.show_estimates);
1409        assert!(exp.show_costs);
1410        assert_eq!(exp.format, ExplainFormat::Text);
1411    }
1412
1413    #[test]
1414    fn test_builder_no_estimates() {
1415        let exp = QueryExplainer::builder().show_estimates(false).build();
1416        let plan = make_plan(make_scan("?s ?p ?o", IndexType::Spo, 999));
1417        let out = exp.explain_text(&plan);
1418        // Row estimate for the scan should be absent
1419        assert!(!out.contains("999"));
1420    }
1421
1422    #[test]
1423    fn test_builder_no_costs() {
1424        let exp = QueryExplainer::builder().show_costs(false).build();
1425        let plan = make_plan(make_scan("?s ?p ?o", IndexType::Spo, 10));
1426        let out = exp.explain_text(&plan);
1427        assert!(!out.contains("5.0000"));
1428    }
1429
1430    #[test]
1431    fn test_builder_json_format() {
1432        let exp = QueryExplainer::builder()
1433            .format(ExplainFormat::Json)
1434            .build();
1435        let plan = make_plan(make_scan("?s ?p ?o", IndexType::Spo, 10));
1436        let out = exp.explain(&plan); // uses configured format
1437        let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
1438        assert!(v.is_object());
1439    }
1440
1441    #[test]
1442    fn test_builder_dot_format() {
1443        let exp = QueryExplainer::builder().format(ExplainFormat::Dot).build();
1444        let plan = make_plan(make_scan("?s ?p ?o", IndexType::Spo, 10));
1445        let out = exp.explain(&plan);
1446        assert!(out.contains("digraph"));
1447    }
1448
1449    // ── Nested / complex plans ────────────────────────────────────────────────
1450
1451    #[test]
1452    fn test_nested_plan_text() {
1453        // DISTINCT(SORT(FILTER(HASH_JOIN(scan1, scan2))))
1454        let s1 = make_scan("?s a ?t", IndexType::Pos, 500);
1455        let s2 = make_scan("?s foaf:name ?n", IndexType::Spo, 200);
1456        let join = PlanNode::hash_join(s1, s2, vec!["?s".into()]);
1457        let filter = PlanNode::filter("LANG(?n) = 'en'", join);
1458        let sort = PlanNode::sort(vec!["+?n".into()], filter);
1459        let distinct = PlanNode::distinct(sort);
1460        let plan = QueryPlan {
1461            root: distinct,
1462            estimated_cost: 42.7,
1463            estimated_cardinality: 150,
1464        };
1465        let out = QueryExplainer::new().explain_text(&plan);
1466        assert!(out.contains("Distinct"));
1467        assert!(out.contains("Sort"));
1468        assert!(out.contains("Filter"));
1469        assert!(out.contains("HashJoin"));
1470        assert!(out.contains("TripleScan"));
1471    }
1472
1473    #[test]
1474    fn test_nested_plan_json_roundtrip() {
1475        let scan = make_scan("?s ?p ?o", IndexType::Spo, 100);
1476        let filter = PlanNode::filter("?o > 0", scan);
1477        let plan = QueryPlan {
1478            root: filter,
1479            estimated_cost: std::f64::consts::PI,
1480            estimated_cardinality: 80,
1481        };
1482        let exp = QueryExplainer::new();
1483        let json = exp.explain_json(&plan);
1484        let decoded: QueryPlan = serde_json::from_str(&json).expect("roundtrip failed");
1485        assert_eq!(decoded.estimated_cardinality, 80);
1486        assert!((decoded.estimated_cost - std::f64::consts::PI).abs() < 1e-9);
1487    }
1488
1489    #[test]
1490    fn test_deeply_nested_node_count() {
1491        // 5-level deep chain
1492        let s = make_scan("?s ?p ?o", IndexType::FullScan, 10);
1493        let f = PlanNode::filter("true", s);
1494        let so = PlanNode::sort(vec![], f);
1495        let li = PlanNode::limit(5, 0, so);
1496        let di = PlanNode::distinct(li);
1497        assert_eq!(di.node_count(), 5);
1498        assert_eq!(di.depth(), 4);
1499    }
1500
1501    #[test]
1502    fn test_subquery_in_text() {
1503        let inner_scan = make_scan("?x ?y ?z", IndexType::FullScan, 5);
1504        let inner_plan = QueryPlan {
1505            root: inner_scan,
1506            estimated_cost: 1.0,
1507            estimated_cardinality: 5,
1508        };
1509        let outer_scan = make_scan("?a ?b ?c", IndexType::Spo, 100);
1510        let sub = PlanNode::Subquery {
1511            plan: Box::new(inner_plan),
1512        };
1513        let join = PlanNode::hash_join(outer_scan, sub, vec!["?x".into()]);
1514        let plan = make_plan(join);
1515        let out = QueryExplainer::new().explain_text(&plan);
1516        assert!(out.contains("Subquery"));
1517        assert!(out.contains("HashJoin"));
1518    }
1519}