Skip to main content

spg_engine/
reorder.rs

1// pedantic doc_markdown flags the embedded algorithm-spec block;
2// allowing at the module level keeps the spec readable.
3#![allow(clippy::doc_markdown)]
4
5//! v6.2.3 — JOIN reorder planner pass.
6//!
7//! Runs after parse + clock rewrite + ORDER BY position
8//! resolution. For SelectStatements with multiple INNER-joined
9//! tables, picks an ordering that minimises the cumulative
10//! nested-loop work — leveraging v6.2.0's `Statistics` for
11//! per-edge selectivity estimates.
12//!
13//! Algorithm:
14//!
15//!   * Identify tables: `from.primary` + `from.joins[*]`.
16//!   * Identify edges: each `INNER JOIN ... ON <expr>` adds one
17//!     edge whose endpoints are the table names referenced by
18//!     the ON expression (extracted by walking `ColumnName`
19//!     nodes). LEFT / CROSS joins disable reorder — they have
20//!     semantics-preserving order constraints we don't unpack
21//!     in v6.2.3.
22//!   * Enumerate orderings:
23//!       - For `n ≤ 4` tables: brute force all `n!` orderings.
24//!       - For `n > 4`: greedy — pick the smallest table first,
25//!         then at each step pick the next table that gives the
26//!         smallest expected output size given the edges already
27//!         applicable.
28//!   * Cost an ordering: walk left-to-right tracking the running
29//!     output size. Every edge becomes applicable as soon as both
30//!     its endpoint tables are in the prefix; multiplying the
31//!     running size by `selectivity::equal(stats, …) / n_distinct`
32//!     for that edge updates the size. Step cost = `running_size ×
33//!     new_table_size`. Total cost = sum of step costs.
34//!   * Pick the minimum-cost ordering and rewrite `from.primary` +
35//!     `from.joins` in that order. ON predicates travel with their
36//!     edges — they re-attach to whichever join introduces both
37//!     endpoint tables.
38//!
39//! All of v6.2.3 ships pure-AST rewriting. The executor at
40//! `exec_joined_select` doesn't change shape — it consumes the
41//! newly-ordered FROM clause unchanged.
42
43use alloc::collections::BTreeMap;
44use alloc::string::String;
45use alloc::vec::Vec;
46
47use spg_sql::ast::{ColumnName, Expr, FromClause, FromJoin, JoinKind, SelectStatement, TableRef};
48
49use crate::selectivity;
50use crate::statistics::Statistics;
51use spg_storage::Catalog;
52
53/// v6.2.3 — full-enumeration cap. v6.2.x can re-tune; the value
54/// determines `n!` plan-space size (4! = 24, 5! = 120, 6! = 720
55/// — 6 is on the verge of "noticeable" in micro-bench terms).
56pub const FULL_ENUM_MAX: usize = 4;
57
58/// v6.2.3 — entry point. Rewrites `stmt.from` (when present) so
59/// the join chain is in cost-minimising order. No-op when:
60///   - `stmt.from` is `None` or has no joins
61///   - any join is LEFT / CROSS (semantics-sensitive)
62///   - any ON predicate can't be resolved to a pair of endpoint
63///     tables (the conservative fallback keeps the user's order)
64/// v6.2.3 test-only — computes the chosen order WITHOUT mutating
65/// the statement. Returns `None` when the pass would no-op.
66pub fn choose_order_for_test(
67    stmt: &SelectStatement,
68    catalog: &Catalog,
69    stats: &Statistics,
70) -> Option<Vec<usize>> {
71    let mut clone = stmt.clone();
72    choose_order_inner(&mut clone, catalog, stats)
73}
74
75fn choose_order_inner(
76    stmt: &mut SelectStatement,
77    catalog: &Catalog,
78    stats: &Statistics,
79) -> Option<Vec<usize>> {
80    let from = stmt.from.as_mut()?;
81    if from.joins.is_empty() {
82        return None;
83    }
84    if from
85        .joins
86        .iter()
87        .any(|j| !matches!(j.kind, JoinKind::Inner))
88    {
89        return None;
90    }
91    let mut tables: Vec<TableRef> = Vec::with_capacity(1 + from.joins.len());
92    tables.push(from.primary.clone());
93    for j in &from.joins {
94        tables.push(j.table.clone());
95    }
96    let n = tables.len();
97    let mut alias_to_idx: BTreeMap<String, usize> = BTreeMap::new();
98    for (i, t) in tables.iter().enumerate() {
99        let key = t.alias.clone().unwrap_or_else(|| t.name.clone());
100        alias_to_idx.insert(key, i);
101        if t.alias.is_some() {
102            alias_to_idx.entry(t.name.clone()).or_insert(i);
103        }
104    }
105    let mut edges: Vec<Edge> = Vec::new();
106    for j in &from.joins {
107        let on = j.on.as_ref()?;
108        for sub in split_and_conjunctions(on) {
109            let mut endpoint_set: Vec<usize> = Vec::new();
110            if !collect_referenced_tables(sub, &alias_to_idx, &mut endpoint_set) {
111                return None;
112            }
113            endpoint_set.sort_unstable();
114            endpoint_set.dedup();
115            edges.push(Edge {
116                endpoints: endpoint_set,
117                predicate: sub.clone(),
118                selectivity: estimate_edge_selectivity(sub, &tables, catalog, stats),
119            });
120        }
121    }
122    let mut sizes: Vec<u64> = Vec::with_capacity(n);
123    for t in &tables {
124        let table = catalog.get(&t.name)?;
125        sizes.push(table.rows().len() as u64);
126    }
127    Some(if n <= FULL_ENUM_MAX {
128        best_order_brute(n, &sizes, &edges)
129    } else {
130        best_order_greedy(n, &sizes, &edges)
131    })
132}
133
134pub fn reorder_joins(stmt: &mut SelectStatement, catalog: &Catalog, stats: &Statistics) {
135    reorder_joins_with(stmt, catalog, stats, false);
136}
137
138/// v7.38 元机制 D acceptor — `SPG_TEST_PLAN_DETERMINISTIC=1` makes
139/// cost-based join reorder a no-op so regression tests that pin
140/// "same SQL → same plan order" don't drift when statistics shift.
141/// Production reads call `reorder_joins`(deterministic=false);
142/// the engine's gate is `crates/spg-engine/src/execute.rs::Engine::env_cfg().plan_deterministic`.
143pub fn reorder_joins_with(
144    stmt: &mut SelectStatement,
145    catalog: &Catalog,
146    stats: &Statistics,
147    plan_deterministic: bool,
148) {
149    // v7.37.9 Phase 0 diagnostic — every entry into reorder_joins_with
150    // bumps this counter, even when the function quickly returns
151    // because plan_deterministic=true or there are no joins. The
152    // PHASE-0 counter dump in xtests/dogfood_replay/bin/counter_dump.rs
153    // reads this to measure 'how many times planner asked us to
154    // reorder' across Class A / Class C SQL.
155    REORDER_INNER_RUN_TRIED.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
156    if plan_deterministic {
157        return;
158    }
159    let Some(from) = stmt.from.as_mut() else {
160        return;
161    };
162    if from.joins.is_empty() {
163        return;
164    }
165    // v7.38 — the LEFT/CROSS "disable reorder" rule is too coarse.
166    // Only the *leading run* of INNER joins is reorderable; LEFT /
167    // CROSS joins must stay at their original positions (their
168    // outer side is the prefix produced so far, so promoting a
169    // later table across them would change semantics). The leading
170    // INNERs can swap freely since the trailing LEFT/CROSS ON
171    // predicates resolve by alias, not position. Split the chain
172    // at the first non-Inner join; reorder only the leading prefix;
173    // re-append the preserved trailing joins after rewrite.
174    let split = from
175        .joins
176        .iter()
177        .position(|j| !matches!(j.kind, JoinKind::Inner))
178        .unwrap_or(from.joins.len());
179    if split == 0 {
180        // First join is LEFT/CROSS — `from.primary` is its outer
181        // side, swapping would change semantics. v6.2.3 conservative
182        // bail still applies.
183        return;
184    }
185    // v6.2.3 — reorder is gated on having statistics. PG's
186    // policy: ANALYZE drives the optimizer. Without stats the
187    // pass would be guessing from row counts only, which (as
188    // the v6.2.3 perf gate shows) can produce the same plan as
189    // source order and hide regressions in the executor. The
190    // user-facing contract: "run ANALYZE to opt into JOIN
191    // reorder."
192    if stats.is_empty() {
193        return;
194    }
195    // Build the table list for the LEADING INNER CHAIN ONLY.
196    // primary first + leading INNER joins. Trailing LEFT/CROSS joins
197    // are preserved verbatim by `rewrite_from_with_trailing`.
198    let mut tables: Vec<TableRef> = Vec::with_capacity(1 + split);
199    tables.push(from.primary.clone());
200    for j in &from.joins[..split] {
201        tables.push(j.table.clone());
202    }
203    let n = tables.len();
204    // Build (table_name → alias) and (alias → table_name) so we
205    // can resolve column references to either.
206    let mut alias_to_idx: BTreeMap<String, usize> = BTreeMap::new();
207    for (i, t) in tables.iter().enumerate() {
208        let key = t.alias.clone().unwrap_or_else(|| t.name.clone());
209        alias_to_idx.insert(key, i);
210        // Also register the bare name when the alias differs, so
211        // an ON-expression that uses the unaliased name still
212        // resolves.
213        if t.alias.is_some() {
214            alias_to_idx.entry(t.name.clone()).or_insert(i);
215        }
216    }
217    // Extract edges from each LEADING INNER join's ON predicate.
218    let mut edges: Vec<Edge> = Vec::new();
219    for j in &from.joins[..split] {
220        let Some(on) = j.on.as_ref() else {
221            // INNER without ON is a CROSS in v4.x parser — bail
222            // (we'd lose the user's intent).
223            return;
224        };
225        for sub in split_and_conjunctions(on) {
226            let mut endpoint_set: Vec<usize> = Vec::new();
227            if !collect_referenced_tables(sub, &alias_to_idx, &mut endpoint_set) {
228                return;
229            }
230            endpoint_set.sort_unstable();
231            endpoint_set.dedup();
232            edges.push(Edge {
233                endpoints: endpoint_set,
234                predicate: sub.clone(),
235                selectivity: estimate_edge_selectivity(sub, &tables, catalog, stats),
236            });
237        }
238    }
239    // Per-table row counts from the catalog. Tables that aren't
240    // in the catalog yet (which can happen for a CTE — covered by
241    // a different code path — but we double-check) bail.
242    let mut sizes: Vec<u64> = Vec::with_capacity(n);
243    for t in &tables {
244        let Some(table) = catalog.get(&t.name) else {
245            return;
246        };
247        sizes.push(table.rows().len() as u64);
248    }
249    // Pick an ordering.
250    let order: Vec<usize> = if n <= FULL_ENUM_MAX {
251        best_order_brute(n, &sizes, &edges)
252    } else {
253        best_order_greedy(n, &sizes, &edges)
254    };
255    // No-op when the chosen order matches the input.
256    if order.iter().enumerate().all(|(i, &j)| i == j) {
257        return;
258    }
259    // v7.37.9 Phase 0 diagnostic — bump only when reorder ACTUALLY
260    // permutes the chain (TRIED above counts entries, FIRED counts
261    // mutations).
262    REORDER_INNER_RUN_FIRED.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
263    rewrite_from_with_trailing(from, &tables, &edges, &order, split);
264}
265
266/// v7.37.9 Phase 0 diagnostic counters — see
267/// `.claude/notes/v7.37.9-class-a-c-cascade-closure-plan.md`. These
268/// are read-only telemetry, do not gate any code path.
269pub static REORDER_INNER_RUN_TRIED: core::sync::atomic::AtomicU64 =
270    core::sync::atomic::AtomicU64::new(0);
271pub static REORDER_INNER_RUN_FIRED: core::sync::atomic::AtomicU64 =
272    core::sync::atomic::AtomicU64::new(0);
273
274struct Edge {
275    /// Sorted unique table indices the ON predicate references.
276    endpoints: Vec<usize>,
277    /// The original ON expression. Re-attached to whichever join
278    /// in the reordered chain introduces both endpoints.
279    predicate: Expr,
280    /// 0..1 — fraction of rows expected to satisfy the predicate.
281    /// Cached so plan evaluation doesn't repeatedly walk the
282    /// histogram.
283    selectivity: f64,
284}
285
286/// v6.2.3 — split a conjunction predicate `p1 AND p2 AND …` into
287/// its leaf clauses. Each leaf becomes its own [`Edge`] so the
288/// optimizer can pull tight predicates earlier in the plan tree.
289/// Non-AND expressions return a single-element vec.
290pub(crate) fn split_and_conjunctions(expr: &Expr) -> Vec<&Expr> {
291    use spg_sql::ast::BinOp;
292    let mut out: Vec<&Expr> = Vec::new();
293    let mut stack: Vec<&Expr> = alloc::vec![expr];
294    while let Some(e) = stack.pop() {
295        if let Expr::Binary {
296            op: BinOp::And,
297            lhs,
298            rhs,
299        } = e
300        {
301            stack.push(rhs);
302            stack.push(lhs);
303        } else {
304            out.push(e);
305        }
306    }
307    out
308}
309
310fn collect_referenced_tables(
311    expr: &Expr,
312    alias_to_idx: &BTreeMap<String, usize>,
313    out: &mut Vec<usize>,
314) -> bool {
315    match expr {
316        Expr::Column(ColumnName {
317            qualifier: Some(q), ..
318        }) => {
319            if let Some(&i) = alias_to_idx.get(q) {
320                out.push(i);
321                true
322            } else {
323                false
324            }
325        }
326        Expr::Column(_) => {
327            // Unqualified column — can't resolve without column-set
328            // knowledge. Conservative bail.
329            false
330        }
331        Expr::Literal(_) | Expr::Placeholder(_) => true,
332        Expr::Binary { lhs, rhs, .. } => {
333            collect_referenced_tables(lhs, alias_to_idx, out)
334                && collect_referenced_tables(rhs, alias_to_idx, out)
335        }
336        Expr::Unary { expr, .. } => collect_referenced_tables(expr, alias_to_idx, out),
337        Expr::FunctionCall { args, .. } => args
338            .iter()
339            .all(|a| collect_referenced_tables(a, alias_to_idx, out)),
340        Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
341            collect_referenced_tables(expr, alias_to_idx, out)
342        }
343        Expr::Like {
344            expr: e, pattern, ..
345        } => {
346            collect_referenced_tables(e, alias_to_idx, out)
347                && collect_referenced_tables(pattern, alias_to_idx, out)
348        }
349        // Subqueries / windows / EXTRACT etc. — bail conservatively.
350        _ => false,
351    }
352}
353
354/// Estimate selectivity for an ON-predicate. v6.2.3 MVP recognises
355/// `t1.col1 = t2.col2` and uses `1 / max(n_distinct_left,
356/// n_distinct_right, 1)` as an FK-join estimate (PG's heuristic).
357/// Everything else: PG's `DEFAULT_RANGE = 0.333`.
358fn estimate_edge_selectivity(
359    on: &Expr,
360    tables: &[TableRef],
361    catalog: &Catalog,
362    stats: &Statistics,
363) -> f64 {
364    use spg_sql::ast::BinOp;
365    let Expr::Binary {
366        op: BinOp::Eq,
367        lhs,
368        rhs,
369    } = on
370    else {
371        return selectivity::DEFAULT_RANGE;
372    };
373    let lhs_col = column_ref(lhs);
374    let rhs_col = column_ref(rhs);
375    let (Some(lhs_col), Some(rhs_col)) = (lhs_col, rhs_col) else {
376        return selectivity::DEFAULT_RANGE;
377    };
378    let lhs_distinct = column_n_distinct(&lhs_col, tables, catalog, stats);
379    let rhs_distinct = column_n_distinct(&rhs_col, tables, catalog, stats);
380    let max_distinct = lhs_distinct.max(rhs_distinct).max(1);
381    1.0 / max_distinct as f64
382}
383
384fn column_ref(expr: &Expr) -> Option<(Option<String>, String)> {
385    if let Expr::Column(ColumnName { qualifier, name }) = expr {
386        Some((qualifier.clone(), name.clone()))
387    } else {
388        None
389    }
390}
391
392fn column_n_distinct(
393    col: &(Option<String>, String),
394    tables: &[TableRef],
395    catalog: &Catalog,
396    stats: &Statistics,
397) -> u64 {
398    let Some(alias) = col.0.as_ref() else {
399        return 0;
400    };
401    let Some(table_name) = tables
402        .iter()
403        .find(|t| t.alias.as_deref() == Some(alias.as_str()) || t.name == *alias)
404        .map(|t| t.name.clone())
405    else {
406        return 0;
407    };
408    if let Some(s) = stats.get(&table_name, &col.1) {
409        return s.n_distinct.max(1);
410    }
411    catalog
412        .get(&table_name)
413        .map_or(1, |t| (t.rows().len() as u64).max(1))
414}
415
416fn best_order_brute(n: usize, sizes: &[u64], edges: &[Edge]) -> Vec<usize> {
417    let mut indices: Vec<usize> = (0..n).collect();
418    let mut best_cost = f64::INFINITY;
419    let mut best_order = indices.clone();
420    permute(&mut indices, 0, &mut |perm| {
421        let c = plan_cost(perm, sizes, edges);
422        if c < best_cost {
423            best_cost = c;
424            best_order = perm.to_vec();
425        }
426    });
427    best_order
428}
429
430fn permute<F: FnMut(&[usize])>(arr: &mut Vec<usize>, k: usize, visit: &mut F) {
431    if k >= arr.len() {
432        visit(arr);
433        return;
434    }
435    for i in k..arr.len() {
436        arr.swap(i, k);
437        permute(arr, k + 1, visit);
438        arr.swap(i, k);
439    }
440}
441
442fn best_order_greedy(n: usize, sizes: &[u64], edges: &[Edge]) -> Vec<usize> {
443    // Seed: smallest table.
444    let mut chosen: Vec<usize> = Vec::with_capacity(n);
445    let mut remaining: Vec<usize> = (0..n).collect();
446    let &first = remaining.iter().min_by_key(|&&i| sizes[i]).expect("n > 0");
447    chosen.push(first);
448    remaining.retain(|&x| x != first);
449    while !remaining.is_empty() {
450        // Pick the candidate whose addition produces the smallest
451        // intermediate output size. plan_cost over the current
452        // prefix + candidate works.
453        let mut best_cand = remaining[0];
454        let mut best_cost = f64::INFINITY;
455        for &cand in &remaining {
456            let mut probe = chosen.clone();
457            probe.push(cand);
458            let c = plan_cost(&probe, sizes, edges);
459            if c < best_cost {
460                best_cost = c;
461                best_cand = cand;
462            }
463        }
464        chosen.push(best_cand);
465        remaining.retain(|&x| x != best_cand);
466    }
467    chosen
468}
469
470/// Cost an ordering by simulating the cumulative nested-loop work
471/// + cross-applying every edge whose endpoints are now in the
472/// prefix.
473fn plan_cost(order: &[usize], sizes: &[u64], edges: &[Edge]) -> f64 {
474    // Track output size at each step. Step 0: just the first table.
475    let mut running = sizes[order[0]] as f64;
476    let mut cost = 0.0_f64;
477    let mut in_prefix: Vec<bool> = alloc::vec![false; sizes.len()];
478    in_prefix[order[0]] = true;
479    for &table_idx in &order[1..] {
480        let right = sizes[table_idx] as f64;
481        // Step cost: produce `running × right` candidate rows
482        // before filtering.
483        cost += running * right;
484        in_prefix[table_idx] = true;
485        let mut step_output = running * right;
486        // Apply every edge whose endpoints just became fully
487        // covered by the prefix.
488        for edge in edges {
489            if edge.endpoints.iter().all(|&e| in_prefix[e]) {
490                // Apply selectivity only if at least one endpoint
491                // is this step's `table_idx` — otherwise the
492                // selectivity was already applied at an earlier
493                // step.
494                if edge.endpoints.contains(&table_idx) {
495                    step_output *= edge.selectivity;
496                }
497            }
498        }
499        running = step_output.max(1.0);
500    }
501    cost
502}
503
504fn rewrite_from(from: &mut FromClause, tables: &[TableRef], edges: &[Edge], order: &[usize]) {
505    rewrite_from_with_trailing(from, tables, edges, order, from.joins.len());
506}
507
508/// v7.38 — `rewrite_from` variant aware of trailing LEFT/CROSS joins
509/// that must be preserved verbatim. `split` is the index where the
510/// trailing (non-reorderable) joins begin in the *original* `from.joins`.
511/// The leading INNER chain (primary + `joins[0..split]`) is rewritten
512/// per `order`; `joins[split..]` is re-appended at the end so LEFT /
513/// CROSS semantics are preserved.
514fn rewrite_from_with_trailing(
515    from: &mut FromClause,
516    tables: &[TableRef],
517    edges: &[Edge],
518    order: &[usize],
519    split: usize,
520) {
521    let trailing: alloc::vec::Vec<FromJoin> = from.joins[split..].to_vec();
522    from.primary = tables[order[0]].clone();
523    from.joins.clear();
524    let mut in_prefix: Vec<bool> = alloc::vec![false; tables.len()];
525    in_prefix[order[0]] = true;
526    let mut edges_used: Vec<bool> = alloc::vec![false; edges.len()];
527    for &table_idx in &order[1..] {
528        in_prefix[table_idx] = true;
529        // Pick the edge that joins `table_idx` to the prefix.
530        // There may be multiple; AND them all so we don't lose any
531        // predicate.
532        let mut combined: Option<Expr> = None;
533        for (ei, edge) in edges.iter().enumerate() {
534            if edges_used[ei] {
535                continue;
536            }
537            if edge.endpoints.contains(&table_idx) && edge.endpoints.iter().all(|&e| in_prefix[e]) {
538                edges_used[ei] = true;
539                combined = Some(match combined {
540                    None => edge.predicate.clone(),
541                    Some(prev) => Expr::Binary {
542                        op: spg_sql::ast::BinOp::And,
543                        lhs: alloc::boxed::Box::new(prev),
544                        rhs: alloc::boxed::Box::new(edge.predicate.clone()),
545                    },
546                });
547            }
548        }
549        // Fallback: if no edge applies, build a `TRUE` predicate
550        // so the join shape stays valid. (Shouldn't happen on a
551        // connected join graph; the reorder pass only takes
552        // connected graphs anyway.)
553        let on = combined.unwrap_or_else(|| Expr::Literal(spg_sql::ast::Literal::Bool(true)));
554        from.joins.push(FromJoin {
555            kind: JoinKind::Inner,
556            table: tables[table_idx].clone(),
557            on: Some(on),
558            using_cols: None,
559            natural: false,
560        });
561    }
562    // v7.38 — re-append preserved trailing LEFT/CROSS joins.
563    from.joins.extend(trailing);
564}
565
566/// v7.32 (architecture v2 P3) — force a specific table to drive an
567/// all-INNER join chain, ignoring cost.
568///
569/// `reorder_joins` is cost-based: it weighs table sizes and ON-edge
570/// selectivity but is blind to single-table WHERE restrictions. The
571/// keyed correlated-subquery probe ([`crate::Engine::try_batch_correlated_scalar`])
572/// needs the opposite: the correlated column is a *known* seek key
573/// (an equality against a literal pushed in per surviving group), so
574/// the table owning it must be the driving table — exactly how PG,
575/// MySQL and MariaDB all plan a correlated join subquery (an index
576/// scan on the correlation column, then an index-nested-loop to the
577/// joined table). The driver is a certainty here, not an estimate,
578/// so we skip the cost model entirely.
579///
580/// Returns `true` if `driver_alias` now drives `stmt.from` (already
581/// primary counts as success). Returns `false` without mutating when
582/// the chain isn't all-INNER, the alias can't be found, or an ON
583/// predicate can't be resolved to its endpoint tables.
584pub(crate) fn drive_from(stmt: &mut SelectStatement, driver_alias: &str) -> bool {
585    let Some(from) = stmt.from.as_mut() else {
586        return false;
587    };
588    // Build the table list (primary first) + alias index, mirroring
589    // `choose_order_inner`. Joinless FROMs only succeed when the lone
590    // table already is the driver.
591    let mut tables: Vec<TableRef> = Vec::with_capacity(1 + from.joins.len());
592    tables.push(from.primary.clone());
593    for j in &from.joins {
594        tables.push(j.table.clone());
595    }
596    let mut alias_to_idx: BTreeMap<String, usize> = BTreeMap::new();
597    for (i, t) in tables.iter().enumerate() {
598        let key = t.alias.clone().unwrap_or_else(|| t.name.clone());
599        alias_to_idx.insert(key, i);
600        if t.alias.is_some() {
601            alias_to_idx.entry(t.name.clone()).or_insert(i);
602        }
603    }
604    let Some(&driver_idx) = alias_to_idx.get(driver_alias) else {
605        return false;
606    };
607    if from.joins.is_empty() {
608        // Nothing to swap: the single table either is or isn't the
609        // driver.
610        return driver_idx == 0;
611    }
612    if driver_idx == 0 {
613        return true; // already driving
614    }
615    if from
616        .joins
617        .iter()
618        .any(|j| !matches!(j.kind, JoinKind::Inner))
619    {
620        return false; // LEFT/CROSS: promotion would change semantics
621    }
622    // Extract ON edges. Selectivity is irrelevant for a forced order
623    // (`rewrite_from` never reads it), so charge 0.0 and skip the
624    // catalog/stats round-trip.
625    let mut edges: Vec<Edge> = Vec::new();
626    for j in &from.joins {
627        let Some(on) = j.on.as_ref() else {
628            return false;
629        };
630        for sub in split_and_conjunctions(on) {
631            let mut endpoint_set: Vec<usize> = Vec::new();
632            if !collect_referenced_tables(sub, &alias_to_idx, &mut endpoint_set) {
633                return false;
634            }
635            endpoint_set.sort_unstable();
636            endpoint_set.dedup();
637            edges.push(Edge {
638                endpoints: endpoint_set,
639                predicate: sub.clone(),
640                selectivity: 0.0,
641            });
642        }
643    }
644    // Order = driver first, then a connectivity-preserving sweep so
645    // each appended table shares an edge with the prefix (keeps
646    // `rewrite_from`'s ON re-attachment exact). Disconnected leftovers
647    // fall back to source order.
648    let n = tables.len();
649    let mut order: Vec<usize> = alloc::vec![driver_idx];
650    let mut included: Vec<bool> = alloc::vec![false; n];
651    included[driver_idx] = true;
652    loop {
653        let mut progressed = false;
654        for ti in 0..n {
655            if included[ti] {
656                continue;
657            }
658            let connects = edges.iter().any(|e| {
659                e.endpoints.contains(&ti) && e.endpoints.iter().all(|&x| x == ti || included[x])
660            });
661            if connects {
662                order.push(ti);
663                included[ti] = true;
664                progressed = true;
665            }
666        }
667        if !progressed {
668            break;
669        }
670    }
671    for ti in 0..n {
672        if !included[ti] {
673            order.push(ti);
674        }
675    }
676    rewrite_from(from, &tables, &edges, &order);
677    true
678}
679
680#[cfg(test)]
681mod tests {
682    use super::*;
683    use spg_sql::parser;
684
685    #[test]
686    fn no_joins_is_noop() {
687        let mut stmt = match parser::parse_statement("SELECT * FROM users").unwrap() {
688            spg_sql::ast::Statement::Select(s) => s,
689            _ => panic!(),
690        };
691        let cat = Catalog::new();
692        let stats = Statistics::new();
693        let snap = stmt.clone();
694        reorder_joins(&mut stmt, &cat, &stats);
695        assert_eq!(stmt, snap);
696    }
697
698    #[test]
699    fn five_table_star_picks_fact_first() {
700        // 4 big tables joined to a small fact table via fact.k_i =
701        // big_i.k. Reorder must pick fact first so each
702        // intermediate stays at fact-table cardinality.
703        let mut e = crate::Engine::new();
704        e.execute("CREATE TABLE fact (id INT NOT NULL, k1 INT NOT NULL, k2 INT NOT NULL, k3 INT NOT NULL, k4 INT NOT NULL)").unwrap();
705        for tag in ["big1", "big2", "big3", "big4"] {
706            e.execute(&alloc::format!("CREATE TABLE {tag} (k INT NOT NULL)"))
707                .unwrap();
708        }
709        for i in 0..3 {
710            e.execute(&alloc::format!(
711                "INSERT INTO fact VALUES ({i}, {i}, {i}, {i}, {i})"
712            ))
713            .unwrap();
714        }
715        for tag in ["big1", "big2", "big3", "big4"] {
716            for i in 0..40 {
717                e.execute(&alloc::format!("INSERT INTO {tag} VALUES ({i})"))
718                    .unwrap();
719            }
720        }
721        e.execute("ANALYZE").unwrap();
722        let stmt = e.prepare(
723            "SELECT fact.id FROM big1 \
724             INNER JOIN big2 ON 1 = 1 \
725             INNER JOIN big3 ON 1 = 1 \
726             INNER JOIN big4 ON 1 = 1 \
727             INNER JOIN fact ON fact.k1 = big1.k AND fact.k2 = big2.k AND fact.k3 = big3.k AND fact.k4 = big4.k",
728        )
729        .unwrap();
730        let spg_sql::ast::Statement::Select(sel) = stmt else {
731            panic!()
732        };
733        let from = sel.from.unwrap();
734        assert_eq!(
735            from.primary.name, "fact",
736            "reorder must put fact first; got primary={:?}",
737            from.primary.name
738        );
739    }
740
741    #[test]
742    fn left_join_is_skipped() {
743        // LEFT JOIN has semantics-preserving order; v6.2.3 bails.
744        let mut stmt = match parser::parse_statement(
745            "SELECT * FROM a LEFT JOIN b ON a.id = b.id LEFT JOIN c ON b.id = c.id",
746        )
747        .unwrap()
748        {
749            spg_sql::ast::Statement::Select(s) => s,
750            _ => panic!(),
751        };
752        let cat = Catalog::new();
753        let stats = Statistics::new();
754        let snap = stmt.clone();
755        reorder_joins(&mut stmt, &cat, &stats);
756        assert_eq!(stmt, snap);
757    }
758
759    fn parse_select(sql: &str) -> SelectStatement {
760        match parser::parse_statement(sql).unwrap() {
761            spg_sql::ast::Statement::Select(s) => s,
762            _ => panic!(),
763        }
764    }
765
766    #[test]
767    fn drive_from_promotes_named_table_to_primary() {
768        // The keyed correlated-subquery probe shape: the correlation
769        // table (m2) must drive even though it is written second.
770        let mut s = parse_select(
771            "SELECT e2.category FROM email_analysis e2 \
772             INNER JOIN messages m2 ON e2.message_id = m2.id \
773             WHERE m2.thread_id = 'th-5'",
774        );
775        assert!(drive_from(&mut s, "m2"));
776        let from = s.from.as_ref().unwrap();
777        assert_eq!(from.primary.alias.as_deref(), Some("m2"));
778        assert_eq!(from.primary.name, "messages");
779        assert_eq!(from.joins.len(), 1);
780        assert_eq!(from.joins[0].table.alias.as_deref(), Some("e2"));
781        // The ON edge travelled with the join and is intact.
782        assert!(from.joins[0].on.is_some());
783    }
784
785    #[test]
786    fn drive_from_noop_when_already_primary() {
787        let mut s = parse_select(
788            "SELECT m2.id FROM messages m2 INNER JOIN email_analysis e2 ON e2.message_id = m2.id",
789        );
790        let snap = s.clone();
791        assert!(drive_from(&mut s, "m2"));
792        assert_eq!(s, snap, "already-driving promotion must not mutate");
793    }
794
795    #[test]
796    fn drive_from_refuses_left_join() {
797        // Promoting across a LEFT join would change semantics.
798        let mut s = parse_select(
799            "SELECT e2.id FROM email_analysis e2 LEFT JOIN messages m2 ON e2.message_id = m2.id",
800        );
801        let snap = s.clone();
802        assert!(!drive_from(&mut s, "m2"));
803        assert_eq!(s, snap, "refused promotion must not mutate");
804    }
805
806    #[test]
807    fn drive_from_unknown_alias_is_false() {
808        let mut s = parse_select(
809            "SELECT e2.id FROM email_analysis e2 INNER JOIN messages m2 ON e2.message_id = m2.id",
810        );
811        assert!(!drive_from(&mut s, "nope"));
812    }
813}