Skip to main content

oxisql_parse/optimizer/
join_reorder.rs

1//! Cost-based join reordering pass (Item 1).
2//!
3//! Uses DPccp (Selinger bottom-up DP over connected subsets) for join nests
4//! with ≤ `dp_threshold` atoms, and a GOO (greedy ordering optimisation)
5//! fallback above that threshold.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9
10use crate::cost::{CostEstimate, CostModel};
11use crate::optimizer::expr_util::{
12    equi_key, join_conjuncts, parse_predicate, render, split_conjuncts,
13};
14use crate::plan::{JoinType, LogicalPlan};
15
16use super::OptPass;
17
18// ── Public pass struct ────────────────────────────────────────────────────────
19
20/// Cost-based join reordering pass.
21///
22/// Uses DPccp (bottom-up DP over connected subsets) for ≤ `dp_threshold` atoms
23/// and GOO (greedy ordering optimisation) above that threshold.
24pub struct JoinReorder {
25    /// The cost model used to estimate scan and join costs.
26    pub model: Arc<CostModel>,
27    /// Maximum number of atoms for the DPccp algorithm; above this, GOO is used.
28    pub dp_threshold: usize,
29}
30
31impl JoinReorder {
32    /// Create a new `JoinReorder` pass with the default DP threshold of 12.
33    pub fn new(model: Arc<CostModel>) -> Self {
34        Self {
35            model,
36            dp_threshold: 12,
37        }
38    }
39
40    /// Create a `JoinReorder` pass with an explicit DP threshold.
41    pub fn with_threshold(model: Arc<CostModel>, dp_threshold: usize) -> Self {
42        Self {
43            model,
44            dp_threshold,
45        }
46    }
47}
48
49impl OptPass for JoinReorder {
50    fn name(&self) -> &'static str {
51        "JoinReorder"
52    }
53
54    fn apply(&self, plan: LogicalPlan) -> LogicalPlan {
55        walk(plan, &self.model, self.dp_threshold)
56    }
57}
58
59// ── Internal data structures ──────────────────────────────────────────────────
60
61/// A leaf atom in a flattened inner-join nest.
62struct Atom {
63    plan: LogicalPlan,
64    cost: CostEstimate,
65    /// Lower-cased table name when the atom is a plain `Scan`.
66    table: Option<String>,
67}
68
69/// An edge between two atom indices in the hypergraph.
70struct Edge {
71    left_idx: usize,
72    right_idx: usize,
73    predicates: Vec<String>,
74    selectivity: f64,
75}
76
77// ── Recursive plan walk ───────────────────────────────────────────────────────
78
79/// Recursively walk every plan node, recursing into children first, then
80/// attempting join reordering at any `Inner` / `Cross` join root.
81fn walk(plan: LogicalPlan, model: &Arc<CostModel>, dp_threshold: usize) -> LogicalPlan {
82    match plan {
83        LogicalPlan::Join {
84            left,
85            right,
86            on,
87            join_type,
88            algo_hint,
89        } => {
90            let left_opt = walk(*left, model, dp_threshold);
91            let right_opt = walk(*right, model, dp_threshold);
92
93            let rebuilt = LogicalPlan::Join {
94                left: Box::new(left_opt),
95                right: Box::new(right_opt),
96                on,
97                join_type: join_type.clone(),
98                algo_hint,
99            };
100
101            if matches!(join_type, JoinType::Inner | JoinType::Cross) {
102                attempt_reorder(rebuilt, model, dp_threshold)
103            } else {
104                rebuilt
105            }
106        }
107
108        LogicalPlan::Filter { input, predicate } => LogicalPlan::Filter {
109            input: Box::new(walk(*input, model, dp_threshold)),
110            predicate,
111        },
112        LogicalPlan::Project { input, columns } => LogicalPlan::Project {
113            input: Box::new(walk(*input, model, dp_threshold)),
114            columns,
115        },
116        LogicalPlan::Aggregate {
117            input,
118            group_by,
119            aggregates,
120        } => LogicalPlan::Aggregate {
121            input: Box::new(walk(*input, model, dp_threshold)),
122            group_by,
123            aggregates,
124        },
125        LogicalPlan::Sort { input, order_by } => LogicalPlan::Sort {
126            input: Box::new(walk(*input, model, dp_threshold)),
127            order_by,
128        },
129        LogicalPlan::Limit {
130            input,
131            count,
132            offset,
133        } => LogicalPlan::Limit {
134            input: Box::new(walk(*input, model, dp_threshold)),
135            count,
136            offset,
137        },
138        LogicalPlan::Window { input, functions } => LogicalPlan::Window {
139            input: Box::new(walk(*input, model, dp_threshold)),
140            functions,
141        },
142        LogicalPlan::Cte {
143            name,
144            query,
145            recursive,
146        } => LogicalPlan::Cte {
147            name,
148            query: Box::new(walk(*query, model, dp_threshold)),
149            recursive,
150        },
151        LogicalPlan::SetOp {
152            op,
153            all,
154            left,
155            right,
156        } => LogicalPlan::SetOp {
157            op,
158            all,
159            left: Box::new(walk(*left, model, dp_threshold)),
160            right: Box::new(walk(*right, model, dp_threshold)),
161        },
162        LogicalPlan::Subquery { query, alias } => LogicalPlan::Subquery {
163            query: Box::new(walk(*query, model, dp_threshold)),
164            alias,
165        },
166        LogicalPlan::Exists { subquery, negated } => LogicalPlan::Exists {
167            subquery: Box::new(walk(*subquery, model, dp_threshold)),
168            negated,
169        },
170        LogicalPlan::InSubquery {
171            expr,
172            subquery,
173            negated,
174        } => LogicalPlan::InSubquery {
175            expr,
176            subquery: Box::new(walk(*subquery, model, dp_threshold)),
177            negated,
178        },
179        LogicalPlan::Compute { input, bindings } => LogicalPlan::Compute {
180            input: Box::new(walk(*input, model, dp_threshold)),
181            bindings,
182        },
183        leaf => leaf,
184    }
185}
186
187// ── Nest flattening and reorder ───────────────────────────────────────────────
188
189/// Flatten an `Inner`/`Cross` join root into atoms + predicates, choose an
190/// algorithm, and reassemble.
191fn attempt_reorder(plan: LogicalPlan, model: &Arc<CostModel>, dp_threshold: usize) -> LogicalPlan {
192    let mut atoms: Vec<Atom> = Vec::new();
193    let mut all_preds: Vec<String> = Vec::new();
194
195    flatten_join_nest(plan, model, &mut atoms, &mut all_preds);
196
197    let n = atoms.len();
198    if n <= 1 {
199        return atoms
200            .into_iter()
201            .next()
202            .map(|a| a.plan)
203            .unwrap_or(LogicalPlan::Empty);
204    }
205
206    let edges = build_edges(&atoms, &all_preds);
207
208    if n <= dp_threshold {
209        dpccp(model, atoms, edges, &all_preds)
210    } else {
211        goo(model, atoms, edges, &all_preds)
212    }
213}
214
215/// Recursively flatten an `Inner`/`Cross` join nest into atoms and predicates.
216/// Any non-Inner/Cross join is treated as an opaque atom.
217fn flatten_join_nest(
218    plan: LogicalPlan,
219    model: &Arc<CostModel>,
220    atoms: &mut Vec<Atom>,
221    preds: &mut Vec<String>,
222) {
223    match plan {
224        LogicalPlan::Join {
225            left,
226            right,
227            on,
228            join_type: JoinType::Inner | JoinType::Cross,
229            ..
230        } => {
231            collect_pred_strings(&on, preds);
232            flatten_join_nest(*left, model, atoms, preds);
233            flatten_join_nest(*right, model, atoms, preds);
234        }
235        other => {
236            let table = table_name_of(&other);
237            let cost = model.estimate(&other);
238            atoms.push(Atom {
239                plan: other,
240                cost,
241                table,
242            });
243        }
244    }
245}
246
247fn table_name_of(plan: &LogicalPlan) -> Option<String> {
248    if let LogicalPlan::Scan { table, .. } = plan {
249        Some(table.to_lowercase())
250    } else {
251        None
252    }
253}
254
255fn collect_pred_strings(on: &str, preds: &mut Vec<String>) {
256    if on.is_empty() {
257        return;
258    }
259    if let Some(expr) = parse_predicate(on) {
260        for c in split_conjuncts(expr) {
261            preds.push(render(&c));
262        }
263    } else {
264        preds.push(on.to_string());
265    }
266}
267
268// ── Edge construction ─────────────────────────────────────────────────────────
269
270fn build_edges(atoms: &[Atom], all_preds: &[String]) -> Vec<Edge> {
271    let mut edges: Vec<Edge> = Vec::new();
272
273    for pred in all_preds {
274        let Some(expr) = parse_predicate(pred) else {
275            continue;
276        };
277        let Some((l_ref, r_ref)) = equi_key(&expr) else {
278            continue;
279        };
280
281        let l_idx = find_atom_idx(atoms, &l_ref.qualifier);
282        let r_idx = find_atom_idx(atoms, &r_ref.qualifier);
283
284        let (l_idx, r_idx) = match (l_idx, r_idx) {
285            (Some(l), Some(r)) if l != r => (l.min(r), l.max(r)),
286            _ => continue,
287        };
288
289        let selectivity = 0.1_f64;
290
291        if let Some(existing) = edges
292            .iter_mut()
293            .find(|e| e.left_idx == l_idx && e.right_idx == r_idx)
294        {
295            existing.predicates.push(pred.clone());
296            existing.selectivity *= selectivity;
297        } else {
298            edges.push(Edge {
299                left_idx: l_idx,
300                right_idx: r_idx,
301                predicates: vec![pred.clone()],
302                selectivity,
303            });
304        }
305    }
306
307    edges
308}
309
310fn find_atom_idx(atoms: &[Atom], qualifier: &Option<String>) -> Option<usize> {
311    let q = qualifier.as_deref()?.to_lowercase();
312    atoms
313        .iter()
314        .enumerate()
315        .find(|(_, a)| a.table.as_deref() == Some(q.as_str()))
316        .map(|(i, _)| i)
317}
318
319// ── Predicate routing ─────────────────────────────────────────────────────────
320
321fn preds_crossing(edges: &[Edge], l_mask: u32, r_mask: u32) -> Vec<String> {
322    edges
323        .iter()
324        .filter(|e| {
325            let lb = 1u32 << e.left_idx;
326            let rb = 1u32 << e.right_idx;
327            (l_mask & lb != 0 && r_mask & rb != 0) || (l_mask & rb != 0 && r_mask & lb != 0)
328        })
329        .flat_map(|e| e.predicates.iter().cloned())
330        .collect()
331}
332
333fn selectivity_crossing(edges: &[Edge], l_mask: u32, r_mask: u32) -> f64 {
334    let mut sel = 1.0_f64;
335    let mut found = false;
336    for e in edges {
337        let lb = 1u32 << e.left_idx;
338        let rb = 1u32 << e.right_idx;
339        if (l_mask & lb != 0 && r_mask & rb != 0) || (l_mask & rb != 0 && r_mask & lb != 0) {
340            sel *= e.selectivity;
341            found = true;
342        }
343    }
344    if found {
345        sel
346    } else {
347        1.0
348    }
349}
350
351// ── Graph connectivity ────────────────────────────────────────────────────────
352
353fn is_connected(edges: &[Edge], mask: u32) -> bool {
354    if mask == 0 || mask & (mask - 1) == 0 {
355        return true;
356    }
357    let start = mask.trailing_zeros() as usize;
358    let mut visited: u32 = 1 << start;
359    let mut queue: Vec<usize> = vec![start];
360
361    while let Some(cur) = queue.pop() {
362        for e in edges {
363            let lb = 1u32 << e.left_idx;
364            let rb = 1u32 << e.right_idx;
365            if (lb & mask == 0) || (rb & mask == 0) {
366                continue;
367            }
368            let other = if e.left_idx == cur {
369                e.right_idx
370            } else if e.right_idx == cur {
371                e.left_idx
372            } else {
373                continue;
374            };
375            let ob = 1u32 << other;
376            if visited & ob == 0 {
377                visited |= ob;
378                queue.push(other);
379            }
380        }
381    }
382    visited == mask
383}
384
385fn has_edge_crossing(edges: &[Edge], l_mask: u32, r_mask: u32) -> bool {
386    edges.iter().any(|e| {
387        let lb = 1u32 << e.left_idx;
388        let rb = 1u32 << e.right_idx;
389        (l_mask & lb != 0 && r_mask & rb != 0) || (l_mask & rb != 0 && r_mask & lb != 0)
390    })
391}
392
393// ── Join assembly ─────────────────────────────────────────────────────────────
394
395fn make_join(left: LogicalPlan, right: LogicalPlan, crossing_preds: Vec<String>) -> LogicalPlan {
396    let on = if crossing_preds.is_empty() {
397        String::new()
398    } else if crossing_preds.len() == 1 {
399        crossing_preds.into_iter().next().unwrap_or_default()
400    } else {
401        let exprs: Vec<_> = crossing_preds
402            .iter()
403            .filter_map(|p| parse_predicate(p))
404            .collect();
405        if let Some(combined) = join_conjuncts(exprs) {
406            render(&combined)
407        } else {
408            crossing_preds.join(" AND ")
409        }
410    };
411    let join_type = if on.is_empty() {
412        JoinType::Cross
413    } else {
414        JoinType::Inner
415    };
416    LogicalPlan::Join {
417        left: Box::new(left),
418        right: Box::new(right),
419        on,
420        join_type,
421        algo_hint: None,
422    }
423}
424
425// ── DPccp ────────────────────────────────────────────────────────────────────
426
427fn dpccp(
428    model: &Arc<CostModel>,
429    atoms: Vec<Atom>,
430    edges: Vec<Edge>,
431    all_preds: &[String],
432) -> LogicalPlan {
433    let n = atoms.len();
434    let full_mask: u32 = (1u32 << n) - 1;
435
436    let mut best_plan: HashMap<u32, LogicalPlan> = HashMap::new();
437    let mut best_cost: HashMap<u32, CostEstimate> = HashMap::new();
438
439    for (i, atom) in atoms.iter().enumerate() {
440        let mask = 1u32 << i;
441        best_plan.insert(mask, atom.plan.clone());
442        best_cost.insert(mask, atom.cost.clone());
443    }
444
445    let total: u32 = 1u32 << n;
446    for s in 1..total {
447        if s.count_ones() < 2 {
448            continue;
449        }
450        // For connectivity: if no edges at all, treat single-atom subsets as
451        // trivially connected and process all splits (cross-join graph).
452        let no_edges = edges.is_empty();
453        if !no_edges && !is_connected(&edges, s) {
454            continue;
455        }
456
457        let mut sub = (s - 1) & s;
458        while sub > 0 {
459            let complement = s ^ sub;
460            if complement != 0 && sub < complement {
461                let l_mask = sub;
462                let r_mask = complement;
463
464                let l_ok = no_edges || is_connected(&edges, l_mask);
465                let r_ok = no_edges || is_connected(&edges, r_mask);
466                let edge_ok =
467                    no_edges || has_edge_crossing(&edges, l_mask, r_mask) || s == full_mask;
468
469                if l_ok && r_ok && edge_ok {
470                    let lp = best_plan.get(&l_mask);
471                    let lc = best_cost.get(&l_mask);
472                    let rp = best_plan.get(&r_mask);
473                    let rc = best_cost.get(&r_mask);
474
475                    if let (Some(lp), Some(lc), Some(rp), Some(rc)) = (lp, lc, rp, rc) {
476                        let sel = selectivity_crossing(&edges, l_mask, r_mask);
477                        let new_cost = model.estimate_join_from(lc, rc, sel);
478
479                        let is_better = best_cost
480                            .get(&s)
481                            .map(|c| new_cost.total_cost < c.total_cost)
482                            .unwrap_or(true);
483
484                        if is_better {
485                            let crossing = preds_crossing(&edges, l_mask, r_mask);
486                            let _ = all_preds; // preds already embedded in edges
487                            let new_plan = make_join(lp.clone(), rp.clone(), crossing);
488                            best_plan.insert(s, new_plan);
489                            best_cost.insert(s, new_cost);
490                        }
491                    }
492                }
493            }
494            if sub == 0 {
495                break;
496            }
497            sub = (sub - 1) & s;
498        }
499    }
500
501    best_plan.remove(&full_mask).unwrap_or_else(|| {
502        assemble_linear(
503            atoms.into_iter().map(|a| (a.plan, a.cost)).collect(),
504            &edges,
505        )
506    })
507}
508
509// ── GOO ──────────────────────────────────────────────────────────────────────
510
511struct GooAtom {
512    plan: LogicalPlan,
513    cost: CostEstimate,
514    mask: u32,
515}
516
517fn goo(
518    model: &Arc<CostModel>,
519    atoms: Vec<Atom>,
520    edges: Vec<Edge>,
521    _all_preds: &[String],
522) -> LogicalPlan {
523    let mut remaining: Vec<GooAtom> = atoms
524        .into_iter()
525        .enumerate()
526        .map(|(i, a)| GooAtom {
527            plan: a.plan,
528            cost: a.cost,
529            mask: 1u32 << i,
530        })
531        .collect();
532
533    while remaining.len() > 1 {
534        let mut best_i = 0usize;
535        let mut best_j = 1usize;
536        let mut best_rows = f64::MAX;
537
538        for i in 0..remaining.len() {
539            for j in (i + 1)..remaining.len() {
540                let sel = selectivity_crossing(&edges, remaining[i].mask, remaining[j].mask);
541                let est = model.estimate_join_from(&remaining[i].cost, &remaining[j].cost, sel);
542                if est.rows < best_rows {
543                    best_rows = est.rows;
544                    best_i = i;
545                    best_j = j;
546                }
547            }
548        }
549
550        // Remove higher index first to keep indices stable.
551        let j_entry = remaining.remove(best_j);
552        let i_entry = remaining.remove(best_i);
553
554        let combined_mask = i_entry.mask | j_entry.mask;
555        let sel = selectivity_crossing(&edges, i_entry.mask, j_entry.mask);
556        let crossing = preds_crossing(&edges, i_entry.mask, j_entry.mask);
557        let new_cost = model.estimate_join_from(&i_entry.cost, &j_entry.cost, sel);
558        let new_plan = make_join(i_entry.plan, j_entry.plan, crossing);
559
560        remaining.push(GooAtom {
561            plan: new_plan,
562            cost: new_cost,
563            mask: combined_mask,
564        });
565    }
566
567    remaining
568        .into_iter()
569        .next()
570        .map(|a| a.plan)
571        .unwrap_or(LogicalPlan::Empty)
572}
573
574// ── Linear fallback ───────────────────────────────────────────────────────────
575
576fn assemble_linear(mut plans: Vec<(LogicalPlan, CostEstimate)>, edges: &[Edge]) -> LogicalPlan {
577    if plans.is_empty() {
578        return LogicalPlan::Empty;
579    }
580
581    // Build a left-deep tree: fold right-to-left (pop from end and merge).
582    while plans.len() > 1 {
583        let len = plans.len();
584        let (rp, _rc) = plans.remove(len - 1);
585        let (lp, _lc) = plans.remove(len - 2);
586        // Compute masks approximately: indices into original atoms are not
587        // tracked here, so pass empty edges → cross-product.
588        let _ = edges;
589        let new_plan = make_join(lp, rp, vec![]);
590        plans.push((new_plan, CostEstimate::zero()));
591    }
592
593    plans
594        .into_iter()
595        .next()
596        .map(|(p, _)| p)
597        .unwrap_or(LogicalPlan::Empty)
598}
599
600// ── Tests ─────────────────────────────────────────────────────────────────────
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605    use crate::cost::{CostModel, TableStats};
606
607    fn scan(table: &str) -> LogicalPlan {
608        LogicalPlan::Scan {
609            table: table.to_string(),
610            alias: None,
611            limit: None,
612        }
613    }
614
615    fn inner_join(l: LogicalPlan, r: LogicalPlan, on: &str) -> LogicalPlan {
616        LogicalPlan::Join {
617            left: Box::new(l),
618            right: Box::new(r),
619            on: on.to_string(),
620            join_type: JoinType::Inner,
621            algo_hint: None,
622        }
623    }
624
625    fn left_join(l: LogicalPlan, r: LogicalPlan, on: &str) -> LogicalPlan {
626        LogicalPlan::Join {
627            left: Box::new(l),
628            right: Box::new(r),
629            on: on.to_string(),
630            join_type: JoinType::Left,
631            algo_hint: None,
632        }
633    }
634
635    fn model_3table() -> Arc<CostModel> {
636        Arc::new(
637            CostModel::new()
638                .with_table_stats(
639                    "big",
640                    TableStats {
641                        row_count: 1_000_000,
642                        avg_row_size_bytes: 100,
643                        ..Default::default()
644                    },
645                )
646                .with_table_stats(
647                    "small1",
648                    TableStats {
649                        row_count: 10,
650                        avg_row_size_bytes: 50,
651                        ..Default::default()
652                    },
653                )
654                .with_table_stats(
655                    "small2",
656                    TableStats {
657                        row_count: 10,
658                        avg_row_size_bytes: 50,
659                        ..Default::default()
660                    },
661                ),
662        )
663    }
664
665    /// 3-table chain: big ⨝ small1 ⨝ small2.
666    /// Reordering should produce cost ≤ original.
667    #[test]
668    fn test_3table_small_tables_join_first() {
669        let model = model_3table();
670        let original = inner_join(
671            inner_join(scan("big"), scan("small1"), "big.id = small1.big_id"),
672            scan("small2"),
673            "small1.id = small2.s1_id",
674        );
675
676        let pass = JoinReorder::new(Arc::clone(&model));
677        let reordered = pass.apply(original.clone());
678
679        let orig_cost = model.estimate(&original).total_cost;
680        let reord_cost = model.estimate(&reordered).total_cost;
681        assert!(
682            reord_cost <= orig_cost + 1e-6,
683            "reordered cost ({reord_cost}) should not exceed original ({orig_cost})"
684        );
685    }
686
687    /// Outer-join nests must NOT be reordered.
688    #[test]
689    fn test_outer_join_nest_untouched() {
690        let model = Arc::new(CostModel::new());
691        let plan = left_join(scan("a"), scan("b"), "a.id = b.id");
692
693        let pass = JoinReorder::new(Arc::clone(&model));
694        let result = pass.apply(plan);
695
696        match &result {
697            LogicalPlan::Join { join_type, .. } => {
698                assert_eq!(
699                    *join_type,
700                    JoinType::Left,
701                    "outer join must not be reordered"
702                );
703            }
704            other => panic!("expected Join, got {other:?}"),
705        }
706    }
707
708    /// Cross-join-only region (no predicates) still produces a plan with all atoms.
709    #[test]
710    fn test_cross_join_only_still_connects_atoms() {
711        let model = Arc::new(CostModel::new());
712        let plan = LogicalPlan::Join {
713            left: Box::new(LogicalPlan::Join {
714                left: Box::new(scan("a")),
715                right: Box::new(scan("b")),
716                on: String::new(),
717                join_type: JoinType::Cross,
718                algo_hint: None,
719            }),
720            right: Box::new(scan("c")),
721            on: String::new(),
722            join_type: JoinType::Cross,
723            algo_hint: None,
724        };
725
726        let pass = JoinReorder::new(Arc::clone(&model));
727        let result = pass.apply(plan);
728
729        let text = crate::explain::explain(&result);
730        assert!(text.contains("Scan [a"), "a should be present: {text}");
731        assert!(text.contains("Scan [b"), "b should be present: {text}");
732        assert!(text.contains("Scan [c"), "c should be present: {text}");
733    }
734
735    /// GOO fallback: dp_threshold=3, 4-atom chain goes through GOO.
736    #[test]
737    fn test_goo_fallback_with_4_atoms() {
738        let model = Arc::new(
739            CostModel::new()
740                .with_table_stats(
741                    "a",
742                    TableStats {
743                        row_count: 1_000,
744                        ..Default::default()
745                    },
746                )
747                .with_table_stats(
748                    "b",
749                    TableStats {
750                        row_count: 10,
751                        ..Default::default()
752                    },
753                )
754                .with_table_stats(
755                    "c",
756                    TableStats {
757                        row_count: 10,
758                        ..Default::default()
759                    },
760                )
761                .with_table_stats(
762                    "d",
763                    TableStats {
764                        row_count: 5,
765                        ..Default::default()
766                    },
767                ),
768        );
769        let plan = inner_join(
770            inner_join(
771                inner_join(scan("a"), scan("b"), "a.id = b.a_id"),
772                scan("c"),
773                "b.id = c.b_id",
774            ),
775            scan("d"),
776            "c.id = d.c_id",
777        );
778
779        let pass = JoinReorder::with_threshold(Arc::clone(&model), 3);
780        let result = pass.apply(plan);
781
782        let text = crate::explain::explain(&result);
783        assert!(text.contains("Scan [a"), "a missing: {text}");
784        assert!(text.contains("Scan [b"), "b missing: {text}");
785        assert!(text.contains("Scan [c"), "c missing: {text}");
786        assert!(text.contains("Scan [d"), "d missing: {text}");
787    }
788
789    /// Property: reordered cost ≤ original cost for random table sizes.
790    #[test]
791    fn test_proptest_reorder_cost_le_original() {
792        use proptest::prelude::*;
793        use proptest::test_runner::TestRunner;
794
795        let mut runner = TestRunner::default();
796        runner
797            .run(
798                &(1u64..100_000u64, 1u64..100_000u64, 1u64..100_000u64),
799                |(r_a, r_b, r_c)| {
800                    let model = Arc::new(
801                        CostModel::new()
802                            .with_table_stats(
803                                "a",
804                                TableStats {
805                                    row_count: r_a,
806                                    ..Default::default()
807                                },
808                            )
809                            .with_table_stats(
810                                "b",
811                                TableStats {
812                                    row_count: r_b,
813                                    ..Default::default()
814                                },
815                            )
816                            .with_table_stats(
817                                "c",
818                                TableStats {
819                                    row_count: r_c,
820                                    ..Default::default()
821                                },
822                            ),
823                    );
824                    let original = inner_join(
825                        inner_join(scan("a"), scan("b"), "a.id = b.a_id"),
826                        scan("c"),
827                        "b.id = c.b_id",
828                    );
829                    let pass = JoinReorder::new(Arc::clone(&model));
830                    let reordered = pass.apply(original.clone());
831                    let orig_cost = model.estimate(&original).total_cost;
832                    let reord_cost = model.estimate(&reordered).total_cost;
833                    prop_assert!(
834                        reord_cost <= orig_cost + 1e-6,
835                        "reord={reord_cost} orig={orig_cost}"
836                    );
837                    Ok(())
838                },
839            )
840            .expect("proptest failed");
841    }
842}