Skip to main content

nedb_engine/
sqlplan.rs

1// SPDX-License-Identifier: BUSL-1.1
2// SPDX-FileCopyrightText: © 2026 INTERCHAINED LLC × Claude Sonnet 4.6
3
4//! The execution plan — what the evaluator actually did, and how to render it.
5//!
6//! # Why this exists
7//!
8//! Before this, parsing, semantics, optimisation and execution all lived in one
9//! growing function. That is survivable while there is one execution strategy
10//! and no rewrites; it stops being survivable the moment a query can be run
11//! more than one way, because there is then no place to record WHICH way was
12//! chosen or WHY.
13//!
14//! This is deliberately not a PostgreSQL planner. There is no cost model, no
15//! statistics, and no search over join orders. It is a record of the pipeline
16//! that ran, with the real row counts it moved.
17//!
18//! # The plan is EMITTED by execution, never written alongside it
19//!
20//! Every node here is appended by the executor as it does the work, and the
21//! row counts are the counts it actually observed. That is a design constraint,
22//! not an implementation detail: a plan assembled independently of the executor
23//! can drift out of agreement with it, and an `EXPLAIN` that confidently
24//! describes a pipeline the engine did not run is worse than having no
25//! `EXPLAIN` at all — it sends the reader to optimise a query shape that never
26//! existed.
27//!
28//! For the same reason it is stored as a PIPELINE (a `Vec` of stages) rather
29//! than a tree: the executor is a pipeline — materialise, join, filter,
30//! project, sort, paginate — and a tree structure would imply a generality it
31//! does not have.
32//!
33//! The one exception is a join, which genuinely has two inputs, and
34//! [`Plan::render`] accounts for that by printing them as siblings. Getting
35//! that wrong is not cosmetic: the first version indented the two scans
36//! differently, which reads as "the left relation was scanned inside the scan
37//! of the right one" — a claim about the execution that was simply false.
38//!
39//! # Consequently, `EXPLAIN` here always reports actual rows
40//!
41//! PostgreSQL's bare `EXPLAIN` estimates without executing, and `EXPLAIN
42//! ANALYZE` executes and reports reality. NEDB has no statistics to estimate
43//! from, so an estimate would be a guess dressed as a number. It executes and
44//! reports what happened. Stated in the output so nobody mistakes one for the
45//! other.
46
47use crate::sqljoin::Strategy;
48use crate::sqlselect::JoinKind;
49
50/// One stage of the pipeline that ran.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum Stage {
53    /// A base relation was materialised.
54    Scan {
55        table: String,
56        /// The name its columns are addressed by — the alias when one was
57        /// given. Shown because a plan that does not name its bindings is
58        /// unreadable for a self-join.
59        binding: String,
60        rows: usize,
61    },
62    /// Two inputs were joined.
63    Join {
64        kind: JoinKind,
65        table: String,
66        binding: String,
67        strategy: Strategy,
68        /// Equality key pairs the planner could PROVE usable. Zero means the
69        /// hash path was unavailable, which is the single most useful number
70        /// in the plan when a join is unexpectedly slow.
71        keys: usize,
72        left_rows: usize,
73        right_rows: usize,
74        out_rows: usize,
75        /// The join stopped early because the row budget was already met.
76        early_stopped: bool,
77        /// Rows this join produced and then discarded because the `WHERE`
78        /// clause was evaluated inside it. `None` means the filter ran as a
79        /// separate stage.
80        ///
81        /// Reported separately from the join's own row count precisely so the
82        /// two stay distinguishable: an `ON` predicate and a post-join
83        /// `WHERE` predicate mean different things, and the plan should not
84        /// blur them just because one loop evaluates both.
85        post_filter_removed: Option<usize>,
86    },
87    /// A `WHERE` clause was applied.
88    Filter { in_rows: usize, out_rows: usize },
89    /// The select list was evaluated.
90    Project { columns: usize, out_rows: usize },
91    /// `DISTINCT` removed duplicates.
92    Distinct { in_rows: usize, out_rows: usize },
93    /// `ORDER BY` sorted the rows.
94    Sort { keys: usize, rows: usize },
95    /// A `WHERE` conjunct was pre-applied to a base relation before the join.
96    ///
97    /// The original `WHERE` still runs afterwards — this is a copy, not a
98    /// move, which is what makes it safe for every join type.
99    Prefilter {
100        binding: String,
101        predicates: usize,
102        in_rows: usize,
103        out_rows: usize,
104    },
105    /// `LIMIT` / `OFFSET` were applied.
106    Limit {
107        limit: Option<usize>,
108        offset: Option<usize>,
109        in_rows: usize,
110        out_rows: usize,
111    },
112}
113
114/// The pipeline as a TREE, which is what it actually is.
115///
116/// Every stage has one input except a join, which has two. Tests assert over
117/// this rather than over rendered text, because the bug that shipped in the
118/// first `EXPLAIN` was a TOPOLOGY bug: a join's two scans were printed at
119/// different depths, so `pg_class` read as a child of the scan of
120/// `pg_namespace`. Every assertion at the time checked content — which
121/// relation, how many rows — and content was correct. Structure was not.
122///
123/// ```text
124///   Join            is NOT        Join
125///   ├── Scan A                    └── Scan A
126///   └── Scan B                        └── Scan B
127/// ```
128///
129/// A string assertion can be made to pass by either shape. A tree assertion
130/// cannot.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum PlanTree {
133    Leaf(Stage),
134    Unary { stage: Stage, input: Box<PlanTree> },
135    Binary { stage: Stage, left: Box<PlanTree>, right: Box<PlanTree> },
136}
137
138impl PlanTree {
139    pub fn stage(&self) -> &Stage {
140        match self {
141            PlanTree::Leaf(s) => s,
142            PlanTree::Unary { stage, .. } => stage,
143            PlanTree::Binary { stage, .. } => stage,
144        }
145    }
146
147    /// Inputs, in the order PostgreSQL prints them: outer side first.
148    pub fn children(&self) -> Vec<&PlanTree> {
149        match self {
150            PlanTree::Leaf(_) => vec![],
151            PlanTree::Unary { input, .. } => vec![input],
152            PlanTree::Binary { left, right, .. } => vec![left, right],
153        }
154    }
155
156    /// Total node count, so a test can assert nothing was dropped.
157    pub fn size(&self) -> usize {
158        1 + self.children().iter().map(|c| c.size()).sum::<usize>()
159    }
160
161    /// The deepest path length, which is what distinguishes siblings from
162    /// nesting: two scans under a join give depth 2, one nested under the
163    /// other gives depth 3.
164    pub fn depth(&self) -> usize {
165        1 + self.children().iter().map(|c| c.depth()).max().unwrap_or(0)
166    }
167}
168
169/// What ran, in the order it ran.
170#[derive(Debug, Clone, Default, PartialEq, Eq)]
171pub struct Plan {
172    pub stages: Vec<Stage>,
173    /// Set when the row budget let a stage stop before consuming its input.
174    pub budget: Option<usize>,
175    /// Why the optimiser declined to push a predicate. Recorded rather than
176    /// silent: you cannot tell "correctly refused" from "forgot to look" if
177    /// the decision leaves no trace.
178    pub refusals: Vec<String>,
179}
180
181impl Plan {
182    pub fn push(&mut self, s: Stage) {
183        self.stages.push(s);
184    }
185
186    /// The join stages, for callers that only care how joins were executed.
187    ///
188    /// Keeps the differential tests and the benchmark reading the same report
189    /// the plan is built from, rather than a second source of truth.
190    pub fn joins(&self) -> Vec<&Stage> {
191        self.stages
192            .iter()
193            .filter(|s| matches!(s, Stage::Join { .. }))
194            .collect()
195    }
196
197    /// The strategy of the nth join, if there is one.
198    pub fn join_strategy(&self, n: usize) -> Option<Strategy> {
199        match self.joins().get(n) {
200            Some(Stage::Join { strategy, .. }) => Some(*strategy),
201            _ => None,
202        }
203    }
204
205    /// The strategy of every join, in execution order.
206    pub fn join_strategies(&self) -> Vec<Strategy> {
207        self.stages
208            .iter()
209            .filter_map(|s| match s {
210                Stage::Join { strategy, .. } => Some(*strategy),
211                _ => None,
212            })
213            .collect()
214    }
215
216    /// Proven equality key count of the nth join.
217    pub fn join_keys(&self, n: usize) -> Option<usize> {
218        match self.joins().get(n) {
219            Some(Stage::Join { keys, .. }) => Some(*keys),
220            _ => None,
221        }
222    }
223
224    /// The pipeline as a tree. `None` when nothing ran (`SELECT 1`).
225    ///
226    /// Stages are recorded as: the base scan, then per join its right-hand
227    /// scan followed by the join itself, then the postfix stages. That shape
228    /// is what makes the reconstruction unambiguous.
229    pub fn tree(&self) -> Option<PlanTree> {
230        let mut it = self.stages.iter();
231        let mut node = PlanTree::Leaf(it.next()?.clone());
232        let rest: Vec<&Stage> = it.collect();
233        let mut i = 0usize;
234        while i < rest.len() {
235            // A right-hand input is a Scan, optionally wrapped in the
236            // Prefilter that was pushed into it. The join therefore sits one
237            // or two stages after the scan, and the pair detection has to look
238            // past the prefilter or it would mistake the join for a unary
239            // stage and flatten the tree.
240            let right_len = match (rest.get(i), rest.get(i + 1), rest.get(i + 2)) {
241                (Some(Stage::Scan { .. }), Some(Stage::Join { .. }), _) => Some(1),
242                (
243                    Some(Stage::Scan { .. }),
244                    Some(Stage::Prefilter { .. }),
245                    Some(Stage::Join { .. }),
246                ) => Some(2),
247                _ => None,
248            };
249            if let Some(n) = right_len {
250                let mut right = PlanTree::Leaf(rest[i].clone());
251                if n == 2 {
252                    right = PlanTree::Unary {
253                        stage: rest[i + 1].clone(),
254                        input: Box::new(right),
255                    };
256                }
257                node = PlanTree::Binary {
258                    stage: rest[i + n].clone(),
259                    left: Box::new(node),
260                    right: Box::new(right),
261                };
262                i += n + 1;
263            } else {
264                node = PlanTree::Unary {
265                    stage: rest[i].clone(),
266                    input: Box::new(node),
267                };
268                i += 1;
269            }
270        }
271        Some(node)
272    }
273
274    /// Render as `EXPLAIN` output: one string per line, innermost first, the
275    /// way PostgreSQL nests its plan tree.
276    ///
277    /// The pipeline is linear, so indentation grows monotonically. A reader
278    /// familiar with PostgreSQL's output will read this correctly; a reader who
279    /// is not still sees the order things happened in.
280    pub fn render(&self) -> Vec<String> {
281        let mut out = vec![];
282        if let Some(t) = self.tree() {
283            render_node(&t, 0, &mut out);
284        }
285
286        for r in &self.refusals {
287            out.push(r.clone());
288        }
289        if let Some(b) = self.budget {
290            out.push(format!(
291                "Row budget: {b} — the join was allowed to stop once this many \
292                 rows existed"
293            ));
294        }
295        out.push(
296            "NEDB reports ACTUAL rows, never estimates: it has no statistics to \
297             estimate from, and a guess printed as a number is worse than the truth."
298                .to_string(),
299        );
300        out
301    }
302}
303
304/// Walk the tree, outermost first, each input indented under the stage that
305/// consumes it.
306///
307/// Recursing over the tree is what makes a join's two inputs siblings without
308/// a special case: they are children of the same node, so they get the same
309/// depth by construction. The first version of this walked a flat list and
310/// tried to patch the sibling case by hand, which is how it got the topology
311/// wrong.
312fn render_node(n: &PlanTree, depth: usize, out: &mut Vec<String>) {
313    let indent = "  ".repeat(depth);
314    let arrow = if depth == 0 { String::new() } else { format!("{indent}-> ") };
315    let line = match n.stage() {
316        Stage::Scan { table, binding, rows } => {
317            format!("{arrow}Seq Scan on {}  (actual rows={rows})", named(table, binding))
318        }
319        Stage::Join {
320            kind,
321            table,
322            binding,
323            strategy,
324            keys,
325            left_rows,
326            right_rows,
327            out_rows,
328            early_stopped,
329            post_filter_removed,
330        } => {
331            let k = match keys {
332                0 => "no equality key".to_string(),
333                1 => "1 hash key".to_string(),
334                n => format!("{n} hash keys"),
335            };
336            let stop = if *early_stopped { ", stopped early" } else { "" };
337            let filt = match post_filter_removed {
338                Some(n) => format!(", post-join filter removed {n}"),
339                None => String::new(),
340            };
341            format!(
342                "{arrow}{strategy} {} Join on {} \
343                 ({k}, left={left_rows}, right={right_rows}{stop}{filt}) \
344                 (actual rows={out_rows})",
345                kind_name(*kind),
346                named(table, binding)
347            )
348        }
349        Stage::Filter { in_rows, out_rows } => format!(
350            "{arrow}Filter  (removed {}) (actual rows={out_rows})",
351            in_rows.saturating_sub(*out_rows)
352        ),
353        Stage::Project { columns, out_rows } => {
354            format!("{arrow}Project  ({columns} columns) (actual rows={out_rows})")
355        }
356        Stage::Distinct { in_rows, out_rows } => format!(
357            "{arrow}Unique  (removed {}) (actual rows={out_rows})",
358            in_rows.saturating_sub(*out_rows)
359        ),
360        Stage::Sort { keys, rows } => {
361            format!("{arrow}Sort  ({keys} key(s)) (actual rows={rows})")
362        }
363        Stage::Prefilter { binding, predicates, in_rows, out_rows } => format!(
364            "{arrow}Prefilter on {binding}  ({predicates} pushed, removed {}) \
365             (actual rows={out_rows})",
366            in_rows.saturating_sub(*out_rows)
367        ),
368        Stage::Limit { limit, offset, in_rows, out_rows } => {
369            let l = limit.map(|n| n.to_string()).unwrap_or_else(|| "ALL".into());
370            let o = offset.map(|n| format!(", offset {n}")).unwrap_or_default();
371            format!("{arrow}Limit  ({l}{o}, from {in_rows}) (actual rows={out_rows})")
372        }
373    };
374    out.push(line);
375    for c in n.children() {
376        render_node(c, depth + 1, out);
377    }
378}
379
380/// `orders` or `orders o` — a redundant alias is not repeated.
381fn named(table: &str, binding: &str) -> String {
382    if table == binding {
383        table.to_string()
384    } else {
385        format!("{table} {binding}")
386    }
387}
388
389fn kind_name(k: JoinKind) -> &'static str {
390    match k {
391        JoinKind::Inner => "Inner",
392        JoinKind::Left => "Left",
393        JoinKind::Right => "Right",
394        JoinKind::Full => "Full",
395        JoinKind::Cross => "Cross",
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    fn scan(t: &str, rows: usize) -> Stage {
404        Stage::Scan { table: t.into(), binding: t.into(), rows }
405    }
406
407    #[test]
408    fn an_empty_plan_still_explains_itself() {
409        let p = Plan::default();
410        let r = p.render();
411        assert_eq!(r.len(), 1);
412        assert!(r[0].contains("ACTUAL rows"));
413    }
414
415    #[test]
416    fn a_scan_renders_with_actual_rows() {
417        let mut p = Plan::default();
418        p.push(scan("orders", 1000));
419        let r = p.render();
420        assert!(r[0].starts_with("Seq Scan on orders"), "{:?}", r[0]);
421        assert!(r[0].contains("actual rows=1000"));
422    }
423
424    #[test]
425    fn an_alias_is_shown_but_a_redundant_one_is_not() {
426        let mut p = Plan::default();
427        p.push(Stage::Scan { table: "orders".into(), binding: "o".into(), rows: 1 });
428        assert!(p.render()[0].contains("orders o"));
429
430        let mut p = Plan::default();
431        p.push(scan("orders", 1));
432        assert!(!p.render()[0].contains("orders orders"));
433    }
434
435    #[test]
436    fn the_outermost_stage_is_printed_first() {
437        let mut p = Plan::default();
438        p.push(scan("orders", 100));
439        p.push(Stage::Limit { limit: Some(5), offset: None, in_rows: 100, out_rows: 5 });
440        let r = p.render();
441        assert!(r[0].starts_with("Limit"), "{r:?}");
442        assert!(r[1].contains("Seq Scan"), "{r:?}");
443        // The input is indented under the operation that consumes it.
444        assert!(r[1].starts_with("  -> "), "{:?}", r[1]);
445    }
446
447    #[test]
448    fn a_join_names_its_strategy_and_key_count() {
449        let mut p = Plan::default();
450        p.push(scan("orders", 1000));
451        p.push(Stage::Join {
452            kind: JoinKind::Inner,
453            table: "customers".into(),
454            binding: "c".into(),
455            strategy: Strategy::Hash,
456            keys: 2,
457            left_rows: 1000,
458            right_rows: 500,
459            out_rows: 1922,
460            early_stopped: false,
461            post_filter_removed: None,
462        });
463        let r = p.render();
464        assert!(r[0].contains("Hash Join"), "{:?}", r[0]);
465        assert!(r[0].contains("Inner"));
466        assert!(r[0].contains("2 hash keys"));
467        assert!(r[0].contains("customers c"));
468        assert!(r[0].contains("actual rows=1922"));
469    }
470
471    #[test]
472    fn a_join_with_no_key_says_so_because_that_is_why_it_is_slow() {
473        let mut p = Plan::default();
474        p.push(Stage::Join {
475            kind: JoinKind::Inner,
476            table: "customers".into(),
477            binding: "customers".into(),
478            strategy: Strategy::NestedLoop,
479            keys: 0,
480            left_rows: 1000,
481            right_rows: 500,
482            out_rows: 2500,
483            early_stopped: false,
484            post_filter_removed: None,
485        });
486        let r = p.render();
487        assert!(r[0].contains("Nested Loop"));
488        assert!(r[0].contains("no equality key"), "{:?}", r[0]);
489    }
490
491    #[test]
492    fn early_termination_is_visible() {
493        let mut p = Plan::default();
494        p.push(Stage::Join {
495            kind: JoinKind::Inner,
496            table: "c".into(),
497            binding: "c".into(),
498            strategy: Strategy::Hash,
499            keys: 1,
500            left_rows: 1000,
501            right_rows: 500,
502            out_rows: 20,
503            early_stopped: true,
504            post_filter_removed: None,
505        });
506        p.budget = Some(20);
507        let r = p.render();
508        assert!(r[0].contains("stopped early"), "{:?}", r[0]);
509        assert!(r.iter().any(|l| l.contains("Row budget: 20")));
510    }
511
512    #[test]
513    fn filter_and_unique_report_what_they_removed() {
514        let mut p = Plan::default();
515        p.push(Stage::Filter { in_rows: 1000, out_rows: 117 });
516        p.push(Stage::Distinct { in_rows: 117, out_rows: 4 });
517        let r = p.render();
518        assert!(r.iter().any(|l| l.contains("Unique") && l.contains("removed 113")), "{r:?}");
519        assert!(r.iter().any(|l| l.contains("Filter") && l.contains("removed 883")), "{r:?}");
520    }
521
522    #[test]
523    fn a_joins_two_inputs_are_siblings_not_nested() {
524        // A join is the one stage with two inputs. Printing them at different
525        // depths reads as "the left relation was scanned INSIDE the scan of
526        // the right one", which is not what happened.
527        let mut p = Plan::default();
528        p.push(scan("orders", 10));
529        p.push(scan("customers", 5));
530        p.push(Stage::Join {
531            kind: JoinKind::Inner,
532            table: "customers".into(),
533            binding: "customers".into(),
534            strategy: Strategy::Hash,
535            keys: 1,
536            left_rows: 10,
537            right_rows: 5,
538            out_rows: 7,
539            early_stopped: false,
540            post_filter_removed: None,
541        });
542        let r = p.render();
543        assert!(r[0].contains("Hash Join"), "{r:?}");
544        let orders = r.iter().find(|l| l.contains("orders")).expect("orders scanned");
545        let custs = r.iter().find(|l| l.contains("customers  (actual")).expect("customers");
546        let depth = |l: &str| l.len() - l.trim_start().len();
547        assert_eq!(
548            depth(orders), depth(custs),
549            "the two inputs of a join must be at the same depth\n{r:#?}"
550        );
551        assert!(depth(orders) > depth(&r[0]), "both are nested under the join");
552    }
553
554    // ── structural assertions: topology, not rendered text ──────────────────
555
556    fn join_stage(table: &str) -> Stage {
557        Stage::Join {
558            kind: JoinKind::Inner,
559            table: table.into(),
560            binding: table.into(),
561            strategy: Strategy::Hash,
562            keys: 1,
563            left_rows: 1,
564            right_rows: 1,
565            out_rows: 1,
566            early_stopped: false,
567            post_filter_removed: None,
568        }
569    }
570
571    #[test]
572    fn a_join_node_has_exactly_two_children() {
573        // The distinction the rendered text could not express:
574        //   Join            is NOT     Join
575        //   ├── Scan a                 └── Scan a
576        //   └── Scan b                     └── Scan b
577        let mut p = Plan::default();
578        p.push(scan("a", 1));
579        p.push(scan("b", 1));
580        p.push(join_stage("b"));
581
582        let t = p.tree().expect("a tree");
583        assert!(matches!(t, PlanTree::Binary { .. }), "a join is binary");
584        assert_eq!(t.children().len(), 2, "two inputs, not one nested in the other");
585        assert_eq!(t.size(), 3, "join + two scans");
586        // Two scans as SIBLINGS is depth 2. One nested under the other is 3.
587        assert_eq!(t.depth(), 2, "the inputs are siblings\n{t:#?}");
588        for c in t.children() {
589            assert!(matches!(c, PlanTree::Leaf(Stage::Scan { .. })));
590            assert_eq!(c.children().len(), 0, "a scan consumes nothing");
591        }
592    }
593
594    #[test]
595    fn the_outer_side_is_the_left_child() {
596        // `a` is the FROM relation, `b` is joined to it. Getting these the
597        // wrong way round would make EXPLAIN describe the build and probe
598        // sides backwards.
599        let mut p = Plan::default();
600        p.push(scan("a", 10));
601        p.push(scan("b", 5));
602        p.push(join_stage("b"));
603        let t = p.tree().unwrap();
604        let kids = t.children();
605        assert_eq!(kids[0].stage(), &scan("a", 10), "outer side first");
606        assert_eq!(kids[1].stage(), &scan("b", 5), "inner side second");
607    }
608
609    #[test]
610    fn a_chained_join_nests_on_the_left() {
611        // `FROM a JOIN b JOIN c` — the second join's outer side is the FIRST
612        // join, so the tree leans left and depth grows by one per join.
613        let mut p = Plan::default();
614        p.push(scan("a", 1));
615        p.push(scan("b", 1));
616        p.push(join_stage("b"));
617        p.push(scan("c", 1));
618        p.push(join_stage("c"));
619
620        let t = p.tree().unwrap();
621        assert_eq!(t.size(), 5, "3 scans + 2 joins");
622        assert_eq!(t.depth(), 3, "left-deep: join -> join -> scan");
623        let kids = t.children();
624        assert!(matches!(kids[0], PlanTree::Binary { .. }), "outer side is the first join");
625        assert!(matches!(kids[1], PlanTree::Leaf(_)), "inner side is c");
626        assert_eq!(kids[0].children().len(), 2);
627    }
628
629    #[test]
630    fn unary_stages_wrap_the_whole_tree_below_them() {
631        let mut p = Plan::default();
632        p.push(scan("a", 100));
633        p.push(scan("b", 5));
634        p.push(join_stage("b"));
635        p.push(Stage::Filter { in_rows: 100, out_rows: 7 });
636        p.push(Stage::Limit { limit: Some(2), offset: None, in_rows: 7, out_rows: 2 });
637
638        let t = p.tree().unwrap();
639        assert!(matches!(t.stage(), Stage::Limit { .. }), "the last stage is outermost");
640        assert_eq!(t.children().len(), 1, "a unary stage has one input");
641        let filter = t.children()[0];
642        assert!(matches!(filter.stage(), Stage::Filter { .. }));
643        assert_eq!(filter.children().len(), 1);
644        let join = filter.children()[0];
645        assert_eq!(join.children().len(), 2, "and the join below still has two");
646        assert_eq!(t.size(), 5);
647    }
648
649    #[test]
650    fn a_plan_with_no_stages_has_no_tree() {
651        // `SELECT 1` touches no relation.
652        assert_eq!(Plan::default().tree(), None);
653    }
654
655    #[test]
656    fn the_rendered_depth_agrees_with_the_tree_depth() {
657        // Ties the text back to the structure, so the two cannot drift: if the
658        // renderer ever flattens the tree again, this fails.
659        let mut p = Plan::default();
660        p.push(scan("a", 1));
661        p.push(scan("b", 1));
662        p.push(join_stage("b"));
663        p.push(scan("c", 1));
664        p.push(join_stage("c"));
665        let t = p.tree().unwrap();
666
667        let lines = p.render();
668        let plan_lines: Vec<&String> = lines
669            .iter()
670            .filter(|l| !l.starts_with("NEDB reports") && !l.starts_with("Row budget"))
671            .collect();
672        assert_eq!(plan_lines.len(), t.size(), "every node is rendered once");
673
674        let max_indent = plan_lines
675            .iter()
676            .map(|l| (l.len() - l.trim_start().len()) / 2)
677            .max()
678            .unwrap();
679        assert_eq!(max_indent + 1, t.depth(), "rendered nesting matches the tree");
680    }
681
682    #[test]
683    fn joins_and_join_strategy_read_the_same_report() {
684        let mut p = Plan::default();
685        p.push(scan("a", 1));
686        for s in [Strategy::NestedLoop, Strategy::Hash] {
687            p.push(Stage::Join {
688                kind: JoinKind::Left,
689                table: "b".into(),
690                binding: "b".into(),
691                strategy: s,
692                keys: 1,
693                left_rows: 1,
694                right_rows: 1,
695                out_rows: 1,
696                early_stopped: false,
697            post_filter_removed: None,
698            });
699        }
700        assert_eq!(p.joins().len(), 2);
701        assert_eq!(p.join_strategy(0), Some(Strategy::NestedLoop));
702        assert_eq!(p.join_strategy(1), Some(Strategy::Hash));
703        assert_eq!(p.join_strategy(2), None);
704    }
705}