Skip to main content

krishiv_sql/
late_materialize.rs

1//! Late materialisation of a bounded top-N aggregate.
2//!
3//! A `GROUP BY` that lists a key **and the columns that key determines** carries
4//! those columns through every join and every shuffle beneath it, only to
5//! display a handful of them at the end. This rule groups on the key alone,
6//! takes the top N, and re-fetches the wide columns for the survivors.
7//!
8//! # The query that motivated this, and the number that justifies it
9//!
10//! TPC-H q10 groups by seven columns —
11//! `c_custkey, c_name, c_acctbal, c_phone, n_name, c_address, c_comment` — and
12//! returns twenty rows. Six of the seven are functionally determined by
13//! `c_custkey`; `c_comment` alone averages ~73 B/row.
14//!
15//! Measured at SF100 on the three-node cluster by hand-writing the *narrowed*
16//! query (same joins, same filters, same `LIMIT`, but `GROUP BY c_custkey`):
17//!
18//! ```text
19//!                       real q10 (wide)      narrowed
20//!   s1  customer scan   52 task-s / 3.40 GB  16.5 / 240 MB   14x fewer bytes
21//!   s2  orders⋈customer 8,968 task-s         38.9            230x
22//!   s5  final agg+TopK  1,510                6.3             240x
23//!   wall clock          1784.57 s            120.88 s        14.8x
24//! ```
25//!
26//! `s2` fell **230x while its input bytes fell only ~10x**, so the cost is
27//! superlinear in the wide columns — per-row string handling in the hash join,
28//! not wire volume. That is why nothing about transport fixed it (see the
29//! `q10-dist-s2-is-the-whole-query` record: neither a cross-stage runtime filter
30//! nor a deeper shuffle prefetch moved it).
31//!
32//! # Two rewrites that do not work, so they are not attempted again
33//!
34//! **Declaring the key alone is not enough.** `ParquetTableSpec::with_primary_key`
35//! (shipped separately) gives DataFusion the functional dependency, and
36//! `optimize_projections` will happily shrink a `GROUP BY` with it — but only
37//! `(columns the parent requires) ∪ (minimal FD subset)`. q10 *selects* all
38//! seven grouped columns, so the parent requires them and no key declaration can
39//! prune them.
40//!
41//! **Narrowing the group key alone is not enough either**, and this one was
42//! measured rather than reasoned. Rewriting q10 as `GROUP BY c_custkey` plus
43//! `first_value(...)` per determined column — exactly what a local
44//! composite-key rule would emit — ran at SF100 in **1955.65 s against the
45//! 1784.57 s baseline, ~10% slower**, and *not one stage improved*: `s1` shipped
46//! the identical 3.40 GB. `first_value` still takes the wide columns as
47//! aggregate **inputs**, so they cross every join and shuffle exactly as before.
48//! Narrowing the group key only saves hashing, and hashing was never the cost.
49//!
50//! The cost is the wide columns **flowing through the joins**. Only a join-back
51//! removes them, which is why this rule is non-local: the non-locality is the
52//! optimization, not an inconvenience around it.
53//!
54//! # The rewrite
55//!
56//! ```text
57//!   Sort: revenue DESC, fetch=20
58//!     Projection: c_custkey, c_name, revenue, c_acctbal, n_name, ...
59//!       Aggregate: groupBy=[c_custkey, c_name, c_acctbal, c_phone,
60//!                           n_name, c_address, c_comment]
61//!                  aggr=[sum(...)]
62//!         <customer ⋈ orders ⋈ lineitem ⋈ nation>
63//! ```
64//!
65//! becomes
66//!
67//! ```text
68//!   Sort: revenue DESC, fetch=20                          (unchanged)
69//!     Projection: ...                                     (unchanged)
70//!       Projection: <exactly the aggregate's old schema>
71//!         Inner Join: customer.c_nationkey = nation.n_nationkey
72//!           Inner Join: __krishiv_lm.c_custkey = customer.c_custkey
73//!             SubqueryAlias: __krishiv_lm
74//!               Sort: sum(...) DESC, fetch=20
75//!                 Aggregate: groupBy=[c_custkey], aggr=[sum(...)]
76//!                   <customer ⋈ orders ⋈ lineitem ⋈ nation>
77//!             TableScan: customer
78//!           TableScan: nation
79//! ```
80//!
81//! Only the `Aggregate` node is replaced; everything above it keeps the exact
82//! same schema, so the enclosing `Projection` and `Sort` are untouched. The
83//! inner `Sort` carries the same `fetch`, which is what bounds the join-back to
84//! twenty rows — and is why the rule refuses without one.
85//!
86//! Nothing prunes the wide columns from the narrow branch directly: once the
87//! aggregate stops referencing them, DataFusion's own `optimize_projections`
88//! does it on the next pass, and the `customer` scan under the aggregate drops
89//! to `[c_custkey, c_nationkey]`.
90//!
91//! # Reaching a column through more than one table
92//!
93//! `n_name` lives in `nation`, not in `customer`, so no direct join-back on
94//! `c_custkey` can fetch it. It is still determined: `c_custkey` → (customer's
95//! key) → `c_nationkey`, `c_nationkey = n_nationkey` is an equality of the
96//! original join, and `n_nationkey` is nation's key → `n_name`. DataFusion's FD
97//! machinery does not compose dependencies across join equalities, so this rule
98//! computes its own closure:
99//!
100//! > a table's columns become available when **every** column of its declared
101//! > primary key is either already available or equated — by an inner-join `ON`
102//! > pair in the aggregate's input — to a column that is.
103//!
104//! The same closure decides which group columns may be dropped and, run
105//! forwards, emits the join-back chain, so the two can never disagree about what
106//! is recoverable.
107//!
108//! # Why it is safe
109//!
110//! - **The key really determines the columns.** `Constraint::PrimaryKey` is
111//!   unverified here, exactly as Spark/Databricks `RELY` is. That single
112//!   declaration is doing two jobs — uniqueness (so the join-back returns one
113//!   row per key) and non-nullness (so the key equi-joins at all) — which is
114//!   precisely what a primary key means. `Constraint::Unique` is **refused**:
115//!   DataFusion marks it `nullable`, and a null key would silently fetch the
116//!   wrong row or none.
117//! - **Inner joins only, everywhere.** Every node between the aggregate and its
118//!   base scans must preserve column *values*: an outer join null-pads its
119//!   non-preserved side, so a column re-fetched from the base table would come
120//!   back non-null where the original plan had a null. Any node this rule does
121//!   not understand — a `SubqueryAlias`, a nested aggregate, a union — makes it
122//!   decline rather than guess.
123//! - **The join-back is `Inner`, deliberately.** A `Left` join is the instinct,
124//!   and it is wrong here for a mechanical reason: `PartitionMode::CollectLeft`
125//!   with a join type that emits unmatched build rows cannot be split across
126//!   distributed tasks, so `redistribute_unsplittable_broadcast_joins` would
127//!   convert it to a hash-partitioned join and shuffle the very columns this
128//!   rule exists to keep off the wire. `Inner` is also exactly right
129//!   semantically: the surviving key came from a row that already joined.
130//! - **The ordering is computable before the columns are.** Every `ORDER BY`
131//!   expression must resolve to a retained key column or an aggregate output;
132//!   a sort on a deferred column would need the column it is deferring. Group
133//!   columns the sort names are added back to the key rather than refused.
134//! - **Bounded output only.** Without a `fetch` the join-back re-joins every
135//!   group and the rewrite is a pure loss. See `MAX_LATE_MATERIALIZE_FETCH`.
136//! - **Names cannot be crossed.** Columns are matched by fully-qualified name,
137//!   and a projection that *reuses* an input's qualified name for a different
138//!   expression makes the rule decline; duplicate qualified names across the
139//!   collected scans (a self-join) do too.
140//!
141//! Set `KRISHIV_LATE_MATERIALIZATION=off` to disable.
142
143use datafusion::common::tree_node::Transformed;
144use datafusion::common::{Column, DFSchema, Dependency, NullEquality, Result, TableReference};
145use datafusion::logical_expr::{
146    Aggregate, Expr, LogicalPlan, LogicalPlanBuilder, Projection, SubqueryAlias, TableScan,
147};
148use datafusion::optimizer::{ApplyOrder, OptimizerConfig, OptimizerRule};
149use std::collections::HashSet;
150use std::sync::Arc;
151
152/// Environment switch for late materialisation.
153pub const LATE_MATERIALIZATION_ENV: &str = "KRISHIV_LATE_MATERIALIZATION";
154
155/// Qualifier given to the bounded top-N branch.
156///
157/// The join-back puts the narrowed aggregate and the re-fetched table side by
158/// side, and both carry the key column under its original name. Aliasing one of
159/// them is what keeps `customer.c_custkey` unambiguous; the prefix is deliberately
160/// unusable as a SQL identifier a user would write.
161const TOPN_ALIAS: &str = "__krishiv_lm";
162
163/// Largest `fetch` this rule will rewrite under.
164///
165/// The join-back costs one extra scan of each re-fetched table and pays for it
166/// by removing the deferred columns from every join and shuffle below. That
167/// trade is overwhelming at q10's twenty rows and evaporates as the bound grows:
168/// with no bound at all it is a pure loss, because the "top N" is then every
169/// group and the join-back re-joins all of them.
170///
171/// 10,000 is the same ceiling [`crate::distributed_plan`] uses to decide a
172/// gathered sort is small enough to cut a stage for, and for the same reason —
173/// it is the point past which "a handful of rows" stops being true.
174const MAX_LATE_MATERIALIZE_FETCH: usize = 10_000;
175
176/// Ceiling on how many tables the join-back chain may re-join.
177///
178/// Each link is justified by a declared key, so a long chain is not *wrong* —
179/// but it is a lot of extra joins bought on unverified declarations, and past a
180/// handful the shape is more likely to be something this rule has misread than a
181/// genuine star schema.
182const MAX_DIM_CHAIN: usize = 4;
183
184/// Whether late materialisation is enabled (default: yes).
185pub fn late_materialization_enabled() -> bool {
186    enabled_from(&std::env::var(LATE_MATERIALIZATION_ENV).unwrap_or_default())
187}
188
189/// The switch's parsing, separated from reading the environment.
190///
191/// Kept pure so it can be tested directly: mutating process environment from a
192/// test is unsound under a multi-threaded test runner, and the workspace denies
193/// the `unsafe` that edition 2024 now requires for `set_var`.
194fn enabled_from(value: &str) -> bool {
195    !matches!(
196        value.trim().to_ascii_lowercase().as_str(),
197        "0" | "off" | "false" | "no"
198    )
199}
200
201/// Replace a bounded top-N aggregate's determined grouping columns with a
202/// join-back, so they never enter the joins beneath it.
203#[derive(Debug, Default)]
204pub struct LateMaterializeTopKAggregate {
205    /// Bypass [`late_materialization_enabled`] and always apply.
206    ///
207    /// The env switch cannot be exercised from a test: mutating process
208    /// environment is unsound under a multi-threaded runner and `set_var` is
209    /// unsafe since edition 2024, which this workspace denies. Without this the
210    /// rule's own tests would silently test nothing the day the default flips.
211    forced: bool,
212}
213
214impl LateMaterializeTopKAggregate {
215    /// The rule with its env gate bypassed, for tests and explicit opt-in.
216    #[must_use]
217    pub fn forced() -> Self {
218        Self { forced: true }
219    }
220}
221
222impl OptimizerRule for LateMaterializeTopKAggregate {
223    fn name(&self) -> &str {
224        "late_materialize_topk_aggregate"
225    }
226
227    fn apply_order(&self) -> Option<ApplyOrder> {
228        // Top-down: the bound lives on the `Sort` at the top and the aggregate
229        // is underneath it, so the match starts from the outside in.
230        Some(ApplyOrder::TopDown)
231    }
232
233    fn rewrite(
234        &self,
235        plan: LogicalPlan,
236        _config: &dyn OptimizerConfig,
237    ) -> Result<Transformed<LogicalPlan>> {
238        if !self.forced && !late_materialization_enabled() {
239            return Ok(Transformed::no(plan));
240        }
241        let LogicalPlan::Sort(sort) = &plan else {
242            return Ok(Transformed::no(plan));
243        };
244        let Some(fetch) = sort.fetch.filter(|n| *n <= MAX_LATE_MATERIALIZE_FETCH) else {
245            return Ok(Transformed::no(plan));
246        };
247
248        // Walk down to the aggregate, lowering the sort expressions through
249        // every projection on the way so they can be re-expressed against the
250        // aggregate's own output.
251        let mut sort_exprs: Vec<Expr> = sort.expr.iter().map(|s| s.expr.clone()).collect();
252        let mut node: &LogicalPlan = &sort.input;
253        let mut projections: Vec<&Projection> = Vec::new();
254        loop {
255            match node {
256                LogicalPlan::Projection(proj) => {
257                    let Some(lowered) = lower_exprs_through(proj, &sort_exprs) else {
258                        return Ok(Transformed::no(plan));
259                    };
260                    sort_exprs = lowered;
261                    projections.push(proj);
262                    node = &proj.input;
263                }
264                LogicalPlan::Aggregate(_) => break,
265                _ => return Ok(Transformed::no(plan)),
266            }
267        }
268        let LogicalPlan::Aggregate(agg) = node else {
269            return Ok(Transformed::no(plan));
270        };
271
272        let Some(rewritten) = rewrite_aggregate(agg, &sort_exprs, sort, fetch)? else {
273            return Ok(Transformed::no(plan));
274        };
275
276        // Rebuild the projection chain over the new node, outermost last.
277        let mut rebuilt = rewritten;
278        for proj in projections.into_iter().rev() {
279            rebuilt = LogicalPlan::Projection(Projection::try_new(
280                proj.expr.clone(),
281                Arc::new(rebuilt),
282            )?);
283        }
284        Ok(Transformed::yes(LogicalPlan::Sort(
285            datafusion::logical_expr::Sort {
286                expr: sort.expr.clone(),
287                input: Arc::new(rebuilt),
288                fetch: sort.fetch,
289            },
290        )))
291    }
292}
293
294/// Rewrite one aggregate, or return `None` to leave the plan alone.
295///
296/// `sort_exprs` are the enclosing sort's expressions already lowered to the
297/// aggregate's output schema; `sort` supplies the ordering directions for the
298/// inner bounded sort.
299fn rewrite_aggregate(
300    agg: &Aggregate,
301    sort_exprs: &[Expr],
302    sort: &datafusion::logical_expr::Sort,
303    fetch: usize,
304) -> Result<Option<LogicalPlan>> {
305    // Plain grouping columns only. GROUPING SETS / ROLLUP / CUBE produce
306    // several group lists at once and computed group expressions have no
307    // column to fetch back, so neither has a key to narrow to.
308    let mut group_cols = Vec::with_capacity(agg.group_expr.len());
309    for expr in &agg.group_expr {
310        let Expr::Column(col) = expr else {
311            return Ok(None);
312        };
313        group_cols.push(col.clone());
314    }
315    if group_cols.len() < 2 {
316        // With one grouping column there is nothing determined to defer.
317        return Ok(None);
318    }
319
320    let Some(facts) = InputFacts::collect(&agg.input) else {
321        return Ok(None);
322    };
323    if facts.tables.is_empty() {
324        return Ok(None);
325    }
326
327    // The retained key: the smallest subset of the grouping columns whose
328    // closure still reaches all the others, plus any column the ordering names
329    // (which must be computable before the deferred columns exist).
330    let mut key = facts.minimal_key(&group_cols);
331    for expr in sort_exprs {
332        for col in expr.column_refs() {
333            if group_cols.contains(col) && !key.contains(col) {
334                key.push(col.clone());
335            }
336        }
337    }
338    // Restore the group list's own order, so the narrowed aggregate's schema is
339    // a sub-sequence of the original's rather than an arbitrary permutation.
340    key.sort_by_key(|col| group_cols.iter().position(|g| g == col).unwrap_or(usize::MAX));
341    let deferred: Vec<Column> = group_cols
342        .iter()
343        .filter(|col| !key.contains(col))
344        .cloned()
345        .collect();
346    if deferred.is_empty() || key.is_empty() {
347        return Ok(None);
348    }
349
350    // Every sort expression must be answerable from the narrowed aggregate:
351    // retained key columns and aggregate outputs, nothing else.
352    let agg_schema = agg.schema.as_ref();
353    for expr in sort_exprs {
354        for col in expr.column_refs() {
355            let is_aggregate_output = agg_schema
356                .index_of_column(col)
357                .is_ok_and(|idx| idx >= group_cols.len());
358            if !key.contains(col) && !is_aggregate_output {
359                return Ok(None);
360            }
361        }
362    }
363
364    let Some(chain) = facts.dim_chain(&key, &deferred) else {
365        return Ok(None);
366    };
367
368    // ── the narrow branch: same input, key-only grouping, same bound ────────
369    let key_exprs: Vec<Expr> = key.iter().cloned().map(Expr::Column).collect();
370    let narrow = LogicalPlan::Aggregate(Aggregate::try_new(
371        Arc::clone(&agg.input),
372        key_exprs,
373        agg.aggr_expr.clone(),
374    )?);
375    // `SubqueryAlias` requalifies every field and keeps its *name*, so two
376    // fields that differ only by qualifier — `a.id` and `b.id` in the key, say —
377    // would collide into one ambiguous `__krishiv_lm.id` and every reference
378    // into the branch would be a coin flip.
379    {
380        let mut names = HashSet::new();
381        if !narrow
382            .schema()
383            .fields()
384            .iter()
385            .all(|field| names.insert(field.name().clone()))
386        {
387            return Ok(None);
388        }
389    }
390    let narrow_sort = LogicalPlan::Sort(datafusion::logical_expr::Sort {
391        expr: sort
392            .expr
393            .iter()
394            .zip(sort_exprs)
395            .map(|(original, lowered)| datafusion::logical_expr::SortExpr {
396                expr: lowered.clone(),
397                asc: original.asc,
398                nulls_first: original.nulls_first,
399            })
400            .collect(),
401        input: Arc::new(narrow),
402        fetch: Some(fetch),
403    });
404    let topn = LogicalPlan::SubqueryAlias(SubqueryAlias::try_new(
405        Arc::new(narrow_sort),
406        TableReference::bare(TOPN_ALIAS),
407    )?);
408
409    // ── the join-back ───────────────────────────────────────────────────────
410    let mut joined = topn;
411    for link in &chain {
412        // A key that does not resolve on the side it is meant to index is not
413        // an error DataFusion reports: `join_detailed` drops the pair and the
414        // physical planner produces a **cross join**, which multiplies the
415        // result by the whole dimension table. That is exactly what the first
416        // version of this rule did — it probed with `customer.c_custkey` when
417        // the narrowed branch had already been requalified to
418        // `__krishiv_lm.c_custkey` — and every row came back duplicated.
419        // Refusing here turns a silent wrong answer into no rewrite at all.
420        let resolves = link
421            .probe_keys
422            .iter()
423            .all(|col| index_of(joined.schema(), col).is_some())
424            && link
425                .key_columns
426                .iter()
427                .all(|col| index_of(link.scan.schema(), col).is_some());
428        if !resolves {
429            return Ok(None);
430        }
431        joined = LogicalPlanBuilder::from(joined)
432            .join_detailed(
433                link.scan.clone(),
434                // Inner, not Left. See the module docs: `CollectLeft` cannot be
435                // split across distributed tasks for a join type that emits
436                // unmatched build rows, so a Left join here is converted to a
437                // hash-partitioned one and shuffles the columns this rule
438                // exists to keep off the wire.
439                datafusion::common::JoinType::Inner,
440                (link.probe_keys.clone(), link.key_columns.clone()),
441                None,
442                NullEquality::NullEqualsNothing,
443            )?
444            .build()?;
445    }
446
447    // ── restore the aggregate's exact output schema ──────────────────────────
448    let mut exprs = Vec::with_capacity(agg_schema.fields().len());
449    for (idx, (qualifier, field)) in agg_schema.iter().enumerate() {
450        let target = Column::new(qualifier.cloned(), field.name());
451        // An aggregate's schema is its grouping columns followed by its
452        // aggregate outputs, so a field past `group_cols.len()` is an aggregate
453        // and can only come from the narrowed branch.
454        let source = match group_cols.get(idx).filter(|col| deferred.contains(col)) {
455            // A deferred column comes back from the re-joined table under its
456            // original qualified name, so it needs no alias at all.
457            Some(col) => col.clone(),
458            None => Column::new(Some(TableReference::bare(TOPN_ALIAS)), field.name()),
459        };
460        exprs.push(if source == target {
461            Expr::Column(source)
462        } else {
463            Expr::Column(source).alias_qualified(qualifier.cloned(), field.name())
464        });
465    }
466    Ok(Some(LogicalPlan::Projection(Projection::try_new(
467        exprs,
468        Arc::new(joined),
469    )?)))
470}
471
472/// One link of the join-back: a base table, and the equality that reaches it.
473#[derive(Debug)]
474struct DimLink {
475    /// The `TableScan` node, cloned from the aggregate's input.
476    scan: LogicalPlan,
477    /// The already-available columns matched against, in key order.
478    probe_keys: Vec<Column>,
479    /// This table's declared key columns, in the same order.
480    key_columns: Vec<Column>,
481}
482
483/// A base relation found beneath the aggregate, with the key it declares.
484#[derive(Debug, Clone)]
485struct DimTable {
486    scan: LogicalPlan,
487    /// Every column the scan projects, fully qualified.
488    columns: Vec<Column>,
489    /// The declared **primary** key. Never a merely `Unique` constraint — see
490    /// the module docs on why nullability rules that out.
491    primary_key: Vec<Column>,
492}
493
494/// What the aggregate's input tells us about where columns come from.
495#[derive(Debug, Default)]
496struct InputFacts {
497    tables: Vec<DimTable>,
498    /// Inner-join `ON` pairs, which are the only way a column of one table can
499    /// stand in for a column of another.
500    equalities: Vec<(Column, Column)>,
501}
502
503impl InputFacts {
504    /// Read the base scans and inner-join equalities out of an aggregate input.
505    ///
506    /// `None` means the shape is not one this rule reasons about — an outer
507    /// join, a nested aggregate, a `SubqueryAlias`, a projection that reuses an
508    /// input column's qualified name for a different expression, or two scans
509    /// that would make a qualified name ambiguous. Declining is always safe;
510    /// guessing is not.
511    fn collect(plan: &LogicalPlan) -> Option<Self> {
512        let mut facts = Self::default();
513        facts.walk(plan)?;
514        // A qualified name has to identify exactly one column or every match
515        // below is a coin flip. Two scans of the same table (a self-join) are
516        // the way this happens in practice.
517        let mut seen = HashSet::new();
518        for table in &facts.tables {
519            for col in &table.columns {
520                if !seen.insert(col.flat_name()) {
521                    return None;
522                }
523            }
524        }
525        Some(facts)
526    }
527
528    fn walk(&mut self, plan: &LogicalPlan) -> Option<()> {
529        match plan {
530            LogicalPlan::TableScan(scan) => {
531                // A scan without a declared key is still collected: it cannot
532                // supply a deferred column, but its column names still have to
533                // take part in the ambiguity check below.
534                self.tables.push(dim_table(scan));
535                Some(())
536            }
537            LogicalPlan::Filter(filter) => self.walk(&filter.input),
538            LogicalPlan::Projection(proj) => {
539                projection_preserves_names(proj).then_some(())?;
540                self.walk(&proj.input)
541            }
542            LogicalPlan::Join(join) => {
543                // Inner only: an outer join null-pads its non-preserved side, so
544                // a column re-fetched from the base table would be non-null
545                // where the original plan had a null.
546                (join.join_type == datafusion::common::JoinType::Inner).then_some(())?;
547                for (left, right) in &join.on {
548                    if let (Expr::Column(l), Expr::Column(r)) = (left, right) {
549                        self.equalities.push((l.clone(), r.clone()));
550                    }
551                }
552                self.walk(&join.left)?;
553                self.walk(&join.right)
554            }
555            // Everything else — SubqueryAlias, Aggregate, Union, Window,
556            // Distinct, Limit — either renames columns or changes what a row
557            // means, and this rule has no rule for it.
558            _ => None,
559        }
560    }
561
562    /// Grow `available` by one table whose declared key is reachable from it.
563    ///
564    /// Returns the link that reaches it, or `None` when nothing new is
565    /// reachable. `skip` are tables already in the chain.
566    ///
567    /// This single step is both halves of the rule: run to a fixpoint it decides
568    /// which grouping columns may be dropped, and run forwards it emits the
569    /// join-back. Sharing it is what stops the two from ever disagreeing about
570    /// what is recoverable.
571    fn reach_one(&self, available: &HashSet<String>, skip: &[usize]) -> Option<(usize, DimLink)> {
572        for (index, table) in self.tables.iter().enumerate() {
573            if skip.contains(&index) || table.primary_key.is_empty() {
574                continue;
575            }
576            // Every column of the key must be available, directly or through an
577            // equality with an available column.
578            let mut probe_keys = Vec::with_capacity(table.primary_key.len());
579            let resolved = table.primary_key.iter().all(|key_col| {
580                if available.contains(&key_col.flat_name()) {
581                    probe_keys.push(key_col.clone());
582                    return true;
583                }
584                for (left, right) in &self.equalities {
585                    for (near, far) in [(left, right), (right, left)] {
586                        if near == key_col && available.contains(&far.flat_name()) {
587                            probe_keys.push(far.clone());
588                            return true;
589                        }
590                    }
591                }
592                false
593            });
594            if !resolved {
595                continue;
596            }
597            // Already fully covered: reaching it again would add nothing.
598            if table
599                .columns
600                .iter()
601                .all(|col| available.contains(&col.flat_name()))
602            {
603                continue;
604            }
605            return Some((
606                index,
607                DimLink {
608                    scan: table.scan.clone(),
609                    probe_keys,
610                    key_columns: table.primary_key.clone(),
611                },
612            ));
613        }
614        None
615    }
616
617    /// The columns of table `index`, or an empty slice if there is no such table.
618    ///
619    /// Every index here comes from [`Self::reach_one`], which produced it by
620    /// enumerating `self.tables` — but the workspace denies `indexing_slicing`
621    /// so that "obviously in range" never has to be re-derived by a later
622    /// reader, and an empty slice is the harmless reading of a bad index.
623    fn columns_of(&self, index: usize) -> &[Column] {
624        self.tables.get(index).map_or(&[], |t| t.columns.as_slice())
625    }
626
627    /// Every column reachable from `seed`.
628    fn closure(&self, seed: &[Column]) -> HashSet<String> {
629        let mut available: HashSet<String> = seed.iter().map(Column::flat_name).collect();
630        let mut used: Vec<usize> = Vec::new();
631        while let Some((index, _)) = self.reach_one(&available, &used) {
632            for col in self.columns_of(index) {
633                available.insert(col.flat_name());
634            }
635            used.push(index);
636        }
637        available
638    }
639
640    /// The smallest subset of `group_cols` whose closure still reaches them all.
641    ///
642    /// Greedy removal in the group list's own order, which is both deterministic
643    /// and — since a key is conventionally written first — the order that keeps
644    /// the key and drops its dependents. A different order could pick a
645    /// different minimal set, but never an incorrect one: each removal is
646    /// checked against the set that survives it, so no column is ever justified
647    /// by one that was already dropped.
648    fn minimal_key(&self, group_cols: &[Column]) -> Vec<Column> {
649        let mut retained: Vec<Column> = group_cols.to_vec();
650        let mut index = 0;
651        while let Some(candidate) = retained.get(index).cloned() {
652            let mut without = retained.clone();
653            without.remove(index);
654            if self.closure(&without).contains(&candidate.flat_name()) {
655                retained = without;
656            } else {
657                index += 1;
658            }
659        }
660        retained
661    }
662
663    /// The join-back chain that fetches `deferred` starting from `key`.
664    ///
665    /// Only the links that actually contribute survive: a table reached purely
666    /// as a stepping stone stays, one that supplies nothing and leads nowhere is
667    /// dropped. That pruning is not cosmetic — an unnecessary link could be a
668    /// fact table, and re-joining `lineitem` to fetch twenty rows would cost
669    /// more than the rewrite saves.
670    fn dim_chain(&self, key: &[Column], deferred: &[Column]) -> Option<Vec<DimLink>> {
671        let mut available: HashSet<String> = key.iter().map(Column::flat_name).collect();
672        // What the join-back has actually built so far, as opposed to what the
673        // closure merely knows is determined. The retained key columns live on
674        // the narrowed branch under [`TOPN_ALIAS`], not under their original
675        // table's qualifier, so a probe into them has to be requalified —
676        // without this the pair silently fails to resolve and DataFusion turns
677        // the join into a cross join.
678        let mut materialised: HashSet<String> = HashSet::new();
679        let mut links: Vec<(usize, DimLink)> = Vec::new();
680        let mut used: Vec<usize> = Vec::new();
681        while !deferred
682            .iter()
683            .all(|col| available.contains(&col.flat_name()))
684        {
685            if links.len() >= MAX_DIM_CHAIN {
686                return None;
687            }
688            let (index, mut link) = self.reach_one(&available, &used)?;
689            for probe in &mut link.probe_keys {
690                if !materialised.contains(&probe.flat_name()) {
691                    *probe = Column::new(
692                        Some(TableReference::bare(TOPN_ALIAS)),
693                        probe.name.clone(),
694                    );
695                }
696            }
697            for col in self.columns_of(index) {
698                available.insert(col.flat_name());
699                materialised.insert(col.flat_name());
700            }
701            used.push(index);
702            links.push((index, link));
703        }
704
705        // Walk back from the links that supply a deferred column, keeping every
706        // link whose columns another kept link probes into.
707        let mut wanted: HashSet<String> = deferred.iter().map(Column::flat_name).collect();
708        // Reverse order matters: `links` is in dependency order, so by the time
709        // a link is examined every later link that could probe into it has
710        // already declared what it needs.
711        let mut keep: Vec<bool> = links
712            .iter()
713            .enumerate()
714            .rev()
715            .map(|(_, (index, link))| {
716                let supplies = self
717                    .columns_of(*index)
718                    .iter()
719                    .any(|col| wanted.contains(&col.flat_name()));
720                if supplies {
721                    for probe in &link.probe_keys {
722                        wanted.insert(probe.flat_name());
723                    }
724                }
725                supplies
726            })
727            .collect();
728        keep.reverse();
729        let pruned: Vec<DimLink> = links
730            .into_iter()
731            .zip(keep)
732            .filter_map(|((_, link), keep)| keep.then_some(link))
733            .collect();
734        (!pruned.is_empty()).then_some(pruned)
735    }
736}
737
738/// Read a scan's declared **primary** key out of the functional dependencies
739/// DataFusion attached to its projected schema.
740///
741/// `TableScan::try_new` turns `Constraints` into `FunctionalDependencies` and
742/// projects them, dropping any whose key columns the projection removed — so
743/// reading them here needs no knowledge of the source's full schema.
744///
745/// `Dependency::Single` is uniqueness; `!nullable` is what separates
746/// `Constraint::PrimaryKey` from `Constraint::Unique`. Both are required: the
747/// join-back leans on uniqueness for row counts and on non-nullness for the
748/// equi-join to match at all.
749fn dim_table(scan: &TableScan) -> DimTable {
750    let schema = scan.projected_schema.as_ref();
751    let columns: Vec<Column> = schema
752        .iter()
753        .map(|(qualifier, field)| Column::new(qualifier.cloned(), field.name()))
754        .collect();
755    let primary_key = schema
756        .functional_dependencies()
757        .iter()
758        .find(|dep| dep.mode == Dependency::Single && !dep.nullable)
759        .map(|dep| {
760            dep.source_indices
761                .iter()
762                .filter_map(|idx| columns.get(*idx).cloned())
763                .collect::<Vec<Column>>()
764        })
765        .filter(|key| !key.is_empty())
766        .unwrap_or_default();
767    DimTable {
768        scan: LogicalPlan::TableScan(scan.clone()),
769        columns,
770        primary_key,
771    }
772}
773
774/// Does this projection leave every input column's qualified name meaning what
775/// it meant below?
776///
777/// A projection may compute, drop and reorder freely. What it may **not** do,
778/// for this rule's purposes, is reuse a name the input already has for a
779/// different expression: every column here is matched by qualified name, and
780/// `customer.c_custkey + 1 AS customer.c_custkey` would silently make the
781/// join-back fetch by the wrong value.
782fn projection_preserves_names(proj: &Projection) -> bool {
783    let below: HashSet<String> = proj.input.schema().field_names().into_iter().collect();
784    proj.schema
785        .iter()
786        .enumerate()
787        .all(|(idx, (qualifier, field))| {
788            let out = Column::new(qualifier.cloned(), field.name());
789            if !below.contains(&out.flat_name()) {
790                return true;
791            }
792            matches!(proj.expr.get(idx), Some(Expr::Column(col)) if *col == out)
793        })
794}
795
796/// Rewrite expressions stated over a projection's output into its input.
797///
798/// `None` when some column resolves to a computed expression, which cannot be
799/// pushed below the projection that computes it.
800fn lower_exprs_through(proj: &Projection, exprs: &[Expr]) -> Option<Vec<Expr>> {
801    use datafusion::common::tree_node::TreeNode;
802
803    let mut lowered = Vec::with_capacity(exprs.len());
804    for expr in exprs {
805        let mut unfollowable = false;
806        let rewritten = expr
807            .clone()
808            .transform(|node| {
809                if let Expr::Column(col) = &node {
810                    return match index_of(&proj.schema, col).and_then(|idx| proj.expr.get(idx)) {
811                        Some(inner) => match unalias(inner) {
812                            Some(inner) => Ok(Transformed::yes(inner)),
813                            None => {
814                                unfollowable = true;
815                                Ok(Transformed::no(node))
816                            }
817                        },
818                        // Not this projection's column at all — a literal's
819                        // sibling, or already an input column. Leave it.
820                        None => Ok(Transformed::no(node)),
821                    };
822                }
823                Ok(Transformed::no(node))
824            })
825            .ok()?;
826        if unfollowable {
827            return None;
828        }
829        lowered.push(rewritten.data);
830    }
831    Some(lowered)
832}
833
834/// Strip aliases down to the column underneath, if that is all there is.
835fn unalias(expr: &Expr) -> Option<Expr> {
836    match expr {
837        Expr::Column(_) => Some(expr.clone()),
838        Expr::Alias(alias) => unalias(&alias.expr),
839        _ => None,
840    }
841}
842
843/// Position of `col` in `schema`, or `None` if it is not there.
844fn index_of(schema: &DFSchema, col: &Column) -> Option<usize> {
845    schema.index_of_column(col).ok()
846}
847
848#[cfg(test)]
849#[allow(clippy::unwrap_used, clippy::expect_used)]
850mod tests {
851    use super::*;
852    use datafusion::arrow::array::{Array as _, Decimal128Array, Int64Array, StringArray};
853    use datafusion::common::get_required_group_by_exprs_indices;
854    use datafusion::arrow::datatypes::{DataType, Field, Schema};
855    use datafusion::arrow::record_batch::RecordBatch;
856    use datafusion::common::{Constraint, Constraints};
857    use datafusion::datasource::MemTable;
858    use datafusion::execution::session_state::SessionStateBuilder;
859    use datafusion::prelude::SessionContext;
860
861    /// `customer`: a primary key plus the wide columns it determines.
862    fn customer_table(with_key: bool) -> Arc<MemTable> {
863        let schema = Arc::new(Schema::new(vec![
864            Field::new("c_custkey", DataType::Int64, false),
865            Field::new("c_name", DataType::Utf8, false),
866            Field::new("c_address", DataType::Utf8, false),
867            Field::new("c_nationkey", DataType::Int64, false),
868            Field::new("c_phone", DataType::Utf8, false),
869            Field::new("c_acctbal", DataType::Decimal128(15, 2), false),
870            Field::new("c_comment", DataType::Utf8, false),
871        ]));
872        let batch = RecordBatch::try_new(
873            Arc::clone(&schema),
874            vec![
875                Arc::new(Int64Array::from(vec![1i64, 2, 3, 4])),
876                Arc::new(StringArray::from(vec!["alice", "bob", "cara", "dan"])),
877                Arc::new(StringArray::from(vec!["a1", "a2", "a3", "a4"])),
878                Arc::new(Int64Array::from(vec![7i64, 7, 8, 8])),
879                Arc::new(StringArray::from(vec!["p1", "p2", "p3", "p4"])),
880                Arc::new(
881                    Decimal128Array::from(vec![100i128, 200, 300, 400])
882                        .with_precision_and_scale(15, 2)
883                        .unwrap(),
884                ),
885                Arc::new(StringArray::from(vec!["k1", "k2", "k3", "k4"])),
886            ],
887        )
888        .unwrap();
889        let table = MemTable::try_new(schema, vec![vec![batch]]).unwrap();
890        Arc::new(if with_key {
891            table.with_constraints(Constraints::new_unverified(vec![Constraint::PrimaryKey(
892                vec![0],
893            )]))
894        } else {
895            table
896        })
897    }
898
899    /// `nation`: reachable only *through* `customer.c_nationkey`, which is what
900    /// exercises the transitive half of the closure.
901    fn nation_table(with_key: bool) -> Arc<MemTable> {
902        let schema = Arc::new(Schema::new(vec![
903            Field::new("n_nationkey", DataType::Int64, false),
904            Field::new("n_name", DataType::Utf8, false),
905        ]));
906        let batch = RecordBatch::try_new(
907            Arc::clone(&schema),
908            vec![
909                Arc::new(Int64Array::from(vec![7i64, 8])),
910                Arc::new(StringArray::from(vec!["GERMANY", "FRANCE"])),
911            ],
912        )
913        .unwrap();
914        let table = MemTable::try_new(schema, vec![vec![batch]]).unwrap();
915        Arc::new(if with_key {
916            table.with_constraints(Constraints::new_unverified(vec![Constraint::PrimaryKey(
917                vec![0],
918            )]))
919        } else {
920            table
921        })
922    }
923
924    /// `orders`: the fact side. Several rows per customer, so a join-back that
925    /// duplicated rows would show up immediately in the aggregate values.
926    fn orders_table() -> Arc<MemTable> {
927        let schema = Arc::new(Schema::new(vec![
928            Field::new("o_orderkey", DataType::Int64, false),
929            Field::new("o_custkey", DataType::Int64, false),
930            Field::new("o_totalprice", DataType::Decimal128(15, 2), false),
931        ]));
932        let batch = RecordBatch::try_new(
933            Arc::clone(&schema),
934            vec![
935                Arc::new(Int64Array::from(vec![10i64, 11, 12, 13, 14, 15])),
936                Arc::new(Int64Array::from(vec![1i64, 1, 2, 3, 3, 3])),
937                Arc::new(
938                    Decimal128Array::from(vec![500i128, 700, 900, 100, 200, 300])
939                        .with_precision_and_scale(15, 2)
940                        .unwrap(),
941                ),
942            ],
943        )
944        .unwrap();
945        let table = MemTable::try_new(schema, vec![vec![batch]]).unwrap();
946        Arc::new(table.with_constraints(Constraints::new_unverified(vec![
947            Constraint::PrimaryKey(vec![0]),
948        ])))
949    }
950
951    fn context(with_rule: bool, with_keys: bool) -> SessionContext {
952        let mut builder = SessionStateBuilder::new().with_default_features();
953        if with_rule {
954            builder =
955                builder.with_optimizer_rule(Arc::new(LateMaterializeTopKAggregate::forced()));
956        }
957        let ctx = SessionContext::new_with_state(builder.build());
958        ctx.register_table("customer", customer_table(with_keys))
959            .unwrap();
960        ctx.register_table("nation", nation_table(with_keys))
961            .unwrap();
962        ctx.register_table("orders", orders_table()).unwrap();
963        ctx
964    }
965
966    async fn rows(ctx: &SessionContext, sql: &str) -> Vec<String> {
967        let batches = ctx.sql(sql).await.unwrap().collect().await.unwrap();
968        let mut out = Vec::new();
969        for batch in &batches {
970            for row in 0..batch.num_rows() {
971                let mut cells = Vec::new();
972                for col in 0..batch.num_columns() {
973                    let casted =
974                        datafusion::arrow::compute::cast(batch.column(col), &DataType::Utf8)
975                            .unwrap();
976                    let array = datafusion::common::cast::as_string_array(&casted).unwrap();
977                    cells.push(if array.is_null(row) {
978                        String::from("NULL")
979                    } else {
980                        array.value(row).to_string()
981                    });
982                }
983                out.push(cells.join("|"));
984            }
985        }
986        out
987    }
988
989    async fn plan_of(ctx: &SessionContext, sql: &str) -> String {
990        format!(
991            "{}",
992            ctx.sql(sql)
993                .await
994                .unwrap()
995                .into_optimized_plan()
996                .unwrap()
997                .display_indent()
998        )
999    }
1000
1001    /// TPC-H q10's shape: seven grouping columns, six determined by the key,
1002    /// one of them (`n_name`) reachable only through a second table.
1003    const Q10_SHAPE: &str = "SELECT c_custkey, c_name, sum(o_totalprice) AS revenue, \
1004        c_acctbal, n_name, c_address, c_phone, c_comment \
1005        FROM customer, orders, nation \
1006        WHERE c_custkey = o_custkey AND c_nationkey = n_nationkey \
1007        GROUP BY c_custkey, c_name, c_acctbal, c_phone, n_name, c_address, c_comment \
1008        ORDER BY revenue DESC LIMIT 20";
1009
1010    #[tokio::test]
1011    async fn the_group_by_narrows_to_the_key_alone() {
1012        let plan = plan_of(&context(true, true), Q10_SHAPE).await;
1013        assert!(
1014            plan.contains("groupBy=[[customer.c_custkey]]"),
1015            "expected a single-column group by:\n{plan}"
1016        );
1017        assert!(
1018            plan.contains(TOPN_ALIAS),
1019            "expected the bounded top-N branch:\n{plan}"
1020        );
1021    }
1022
1023    /// The property the whole rewrite has to buy: the wide columns must stop
1024    /// being read on the branch that feeds the joins. A narrowed `GROUP BY`
1025    /// that still scanned them would have moved the cost, not removed it —
1026    /// which is exactly how the `first_value` rewrite failed.
1027    #[tokio::test]
1028    async fn the_wide_columns_leave_the_aggregate_branch() {
1029        let plan = plan_of(&context(true, true), Q10_SHAPE).await;
1030        let narrow_branch = subtree_under(&plan, &format!("SubqueryAlias: {TOPN_ALIAS}"));
1031        for wide in ["c_comment", "c_address", "c_phone", "c_name"] {
1032            assert!(
1033                !narrow_branch.contains(wide),
1034                "{wide} still crosses the joins under the aggregate:\n\
1035                 --- narrowed branch ---\n{narrow_branch}\n--- whole plan ---\n{plan}"
1036            );
1037        }
1038        // The point of the rewrite, stated positively: the customer scan that
1039        // feeds the joins reads two narrow keys instead of seven wide columns.
1040        assert!(
1041            narrow_branch.contains("TableScan: customer projection=[c_custkey, c_nationkey]"),
1042            "the narrowed branch should scan only the keys:\n{narrow_branch}"
1043        );
1044        // …and the wide columns are still read exactly once, on the join-back.
1045        assert!(
1046            plan.contains("TableScan: customer projection=[c_custkey, c_name, c_address"),
1047            "the join-back must still fetch the deferred columns:\n{plan}"
1048        );
1049    }
1050
1051    /// The lines of `plan` strictly indented under the first line containing
1052    /// `header` — i.e. that node's subtree, and nothing beside it.
1053    ///
1054    /// Substring-matching the whole rendered plan does not work here: the
1055    /// restoring projection at the very top names the alias too, so
1056    /// "everything after the alias appears" is most of the query.
1057    fn subtree_under(plan: &str, header: &str) -> String {
1058        let indent = |line: &str| line.len() - line.trim_start().len();
1059        let mut lines = plan.lines().skip_while(|line| !line.contains(header));
1060        let root = lines.next().expect("subtree root not found");
1061        std::iter::once(root)
1062            .chain(lines.take_while(|line| indent(line) > indent(root)))
1063            .collect::<Vec<_>>()
1064            .join("\n")
1065    }
1066
1067    /// A faster wrong answer is the one outcome worse than a slow right one.
1068    #[tokio::test]
1069    async fn results_are_identical_with_and_without_the_rule() {
1070        for sql in [
1071            Q10_SHAPE,
1072            // ties on the ordering column, where "which N" could differ
1073            "SELECT c_custkey, c_name, count(*) AS n, c_comment FROM customer, orders \
1074             WHERE c_custkey = o_custkey GROUP BY c_custkey, c_name, c_comment \
1075             ORDER BY n DESC, c_custkey LIMIT 3",
1076            // an aggregate over a column that is itself deferred
1077            "SELECT c_custkey, c_name, max(c_comment) AS m, sum(o_totalprice) AS t \
1078             FROM customer, orders WHERE c_custkey = o_custkey \
1079             GROUP BY c_custkey, c_name ORDER BY t DESC LIMIT 2",
1080            // the transitive reach, with the second table's column in the sort
1081            "SELECT c_custkey, n_name, sum(o_totalprice) AS t FROM customer, orders, nation \
1082             WHERE c_custkey = o_custkey AND c_nationkey = n_nationkey \
1083             GROUP BY c_custkey, n_name ORDER BY t DESC, c_custkey LIMIT 4",
1084            // a smaller limit than there are groups, and a larger one
1085            "SELECT c_custkey, c_name, c_address, sum(o_totalprice) AS t \
1086             FROM customer, orders WHERE c_custkey = o_custkey \
1087             GROUP BY c_custkey, c_name, c_address ORDER BY t DESC LIMIT 1",
1088            "SELECT c_custkey, c_name, c_address, sum(o_totalprice) AS t \
1089             FROM customer, orders WHERE c_custkey = o_custkey \
1090             GROUP BY c_custkey, c_name, c_address ORDER BY t DESC LIMIT 100",
1091        ] {
1092            let with = rows(&context(true, true), sql).await;
1093            let without = rows(&context(false, true), sql).await;
1094            assert_eq!(with, without, "results diverged for:\n{sql}");
1095            assert!(!with.is_empty(), "test query returned nothing: {sql}");
1096        }
1097    }
1098
1099    /// Row *order* is part of the answer for an `ORDER BY … LIMIT`, and the
1100    /// join-back reorders rows freely — the outer sort is what puts them back.
1101    #[tokio::test]
1102    async fn the_ordering_survives_the_join_back() {
1103        let out = rows(&context(true, true), Q10_SHAPE).await;
1104        let revenues: Vec<f64> = out
1105            .iter()
1106            .map(|row| row.split('|').nth(2).unwrap().parse().unwrap())
1107            .collect();
1108        assert!(
1109            revenues.windows(2).all(|w| w[0] >= w[1]),
1110            "rows came back out of order: {revenues:?}"
1111        );
1112        assert_eq!(
1113            out,
1114            rows(&context(false, true), Q10_SHAPE).await,
1115            "the ordered result must match the unrewritten plan row for row"
1116        );
1117    }
1118
1119    /// Without a declared key nothing is determined, so there is nothing to
1120    /// defer and the rule must leave the plan exactly as it found it.
1121    #[tokio::test]
1122    async fn an_undeclared_key_is_not_assumed() {
1123        let plan = plan_of(&context(true, false), Q10_SHAPE).await;
1124        assert!(
1125            !plan.contains(TOPN_ALIAS),
1126            "no declared key means no rewrite:\n{plan}"
1127        );
1128        assert_eq!(
1129            rows(&context(true, false), Q10_SHAPE).await,
1130            rows(&context(false, false), Q10_SHAPE).await
1131        );
1132    }
1133
1134    /// Unbounded output makes the join-back a pure loss: the "top N" is every
1135    /// group, so it re-joins all of them for nothing.
1136    #[tokio::test]
1137    async fn an_unbounded_aggregate_is_left_alone() {
1138        let sql = "SELECT c_custkey, c_name, sum(o_totalprice) AS t FROM customer, orders \
1139            WHERE c_custkey = o_custkey GROUP BY c_custkey, c_name ORDER BY t DESC";
1140        let plan = plan_of(&context(true, true), sql).await;
1141        assert!(
1142            !plan.contains(TOPN_ALIAS),
1143            "no fetch means no bound to exploit:\n{plan}"
1144        );
1145    }
1146
1147    /// An outer join below null-pads its non-preserved side, so a column
1148    /// re-fetched from the base table would come back non-null where the
1149    /// original plan had a null.
1150    #[tokio::test]
1151    async fn an_outer_join_below_refuses_the_rewrite() {
1152        let sql = "SELECT c_custkey, c_name, c_comment, sum(o_totalprice) AS t \
1153            FROM customer LEFT JOIN orders ON c_custkey = o_custkey \
1154            GROUP BY c_custkey, c_name, c_comment ORDER BY t DESC LIMIT 5";
1155        let plan = plan_of(&context(true, true), sql).await;
1156        assert!(
1157            !plan.contains(TOPN_ALIAS),
1158            "must not rewrite under an outer join:\n{plan}"
1159        );
1160        assert_eq!(
1161            rows(&context(true, true), sql).await,
1162            rows(&context(false, true), sql).await
1163        );
1164    }
1165
1166    /// Ordering by a column the rewrite wants to defer would need the column
1167    /// before it exists. Keeping it in the key instead is strictly better than
1168    /// refusing, so the rule must still fire — and still be right.
1169    #[tokio::test]
1170    async fn a_sort_on_a_determined_column_keeps_it_in_the_key() {
1171        let sql = "SELECT c_custkey, c_name, c_comment, sum(o_totalprice) AS t \
1172            FROM customer, orders WHERE c_custkey = o_custkey \
1173            GROUP BY c_custkey, c_name, c_comment ORDER BY c_name DESC LIMIT 3";
1174        let plan = plan_of(&context(true, true), sql).await;
1175        assert!(
1176            plan.contains("groupBy=[[customer.c_custkey, customer.c_name]]"),
1177            "the sorted column must stay in the key:\n{plan}"
1178        );
1179        assert_eq!(
1180            rows(&context(true, true), sql).await,
1181            rows(&context(false, true), sql).await
1182        );
1183    }
1184
1185    /// The optimizer runs rules to a fixed point. The rewrite contains a
1186    /// bounded sort over an aggregate — its own trigger shape — so without the
1187    /// "nothing left to defer" guard it would rewrite itself forever.
1188    #[tokio::test]
1189    async fn the_rewrite_is_applied_exactly_once() {
1190        let plan = plan_of(&context(true, true), Q10_SHAPE).await;
1191        assert_eq!(
1192            plan.matches(TOPN_ALIAS).count(),
1193            // one SubqueryAlias node, plus the key column reference the
1194            // restoring projection makes into it
1195            plan.matches(&format!("{TOPN_ALIAS}.")).count() + 1,
1196            "expected a single aliased branch:\n{plan}"
1197        );
1198        assert_eq!(
1199            plan.matches("SubqueryAlias").count(),
1200            1,
1201            "expected exactly one rewrite:\n{plan}"
1202        );
1203    }
1204
1205    /// The join-back must never multiply rows. `orders` has three rows for
1206    /// customer 3, so a chain that re-joined the fact table instead of the
1207    /// dimension would triple the group — visible as a changed count.
1208    #[tokio::test]
1209    async fn the_join_back_does_not_duplicate_rows() {
1210        let sql = "SELECT c_custkey, c_name, c_comment, count(*) AS n \
1211            FROM customer, orders WHERE c_custkey = o_custkey \
1212            GROUP BY c_custkey, c_name, c_comment ORDER BY n DESC, c_custkey LIMIT 10";
1213        let with = rows(&context(true, true), sql).await;
1214        assert_eq!(with, rows(&context(false, true), sql).await);
1215        assert_eq!(with.len(), 3, "expected one row per customer: {with:?}");
1216    }
1217
1218    /// A self-join makes a qualified name ambiguous, and every match in this
1219    /// rule is by qualified name.
1220    #[tokio::test]
1221    async fn a_self_join_is_refused() {
1222        let sql = "SELECT a.c_custkey, a.c_name, a.c_comment, count(*) AS n \
1223            FROM customer a, customer b \
1224            WHERE a.c_nationkey = b.c_nationkey \
1225            GROUP BY a.c_custkey, a.c_name, a.c_comment ORDER BY n DESC LIMIT 5";
1226        assert_eq!(
1227            rows(&context(true, true), sql).await,
1228            rows(&context(false, true), sql).await,
1229            "a self-join must not change the answer"
1230        );
1231    }
1232
1233    /// Physical plan text, which is where a lost equijoin key becomes visible:
1234    /// the logical plan looks fine either way.
1235    async fn physical_plan_of(ctx: &SessionContext, sql: &str) -> String {
1236        let logical = ctx.sql(sql).await.unwrap().into_optimized_plan().unwrap();
1237        let physical = ctx.state().create_physical_plan(&logical).await.unwrap();
1238        format!(
1239            "{}",
1240            datafusion::physical_plan::displayable(physical.as_ref()).indent(false)
1241        )
1242    }
1243
1244    /// **The join-back must never become a nested loop or a cross join.**
1245    ///
1246    /// This is the failure the first version of the rule actually had: it
1247    /// probed with `customer.c_custkey` while the narrowed branch had been
1248    /// requalified to `__krishiv_lm.c_custkey`, `join_detailed` dropped the
1249    /// unresolvable pair, and the planner produced a **cross join** against the
1250    /// whole 15M-row customer table. Every row came back duplicated.
1251    ///
1252    /// The same class of bug cost this codebase 18.4x on q2 through the
1253    /// semi-join rules, and neither a results test nor a logical-plan test
1254    /// catches it on its own — only the physical plan does.
1255    #[tokio::test]
1256    async fn the_join_back_is_a_real_equi_join() {
1257        for sql in [
1258            Q10_SHAPE,
1259            "SELECT c_custkey, c_name, c_comment, sum(o_totalprice) AS t \
1260             FROM customer, orders WHERE c_custkey = o_custkey \
1261             GROUP BY c_custkey, c_name, c_comment ORDER BY t DESC LIMIT 5",
1262        ] {
1263            let plan = physical_plan_of(&context(true, true), sql).await;
1264            assert!(
1265                !plan.contains("NestedLoopJoin") && !plan.contains("CrossJoin"),
1266                "the join-back lost its keys for:\n{sql}\n\n{plan}"
1267            );
1268        }
1269    }
1270
1271    /// The inner bound is the entire economics of the rewrite: it is what makes
1272    /// the join-back cost twenty probes instead of fifteen million. If any pass
1273    /// — logical or physical `EnforceSorting` — drops that `fetch`, the rule
1274    /// silently becomes a pessimization that still returns the right answer.
1275    #[tokio::test]
1276    async fn the_inner_bound_survives_physical_planning() {
1277        let plan = physical_plan_of(&context(true, true), Q10_SHAPE).await;
1278        let bounded = plan
1279            .lines()
1280            .filter(|line| line.contains("Sort") && line.contains("fetch=20"))
1281            .count();
1282        assert!(
1283            bounded >= 2,
1284            "expected a bounded sort on the narrowed branch as well as on top, \
1285             found {bounded}:\n{plan}"
1286        );
1287    }
1288
1289    /// The switch has to actually switch it off — a flag that is declared but
1290    /// never read is worse than no flag, because the registry gate makes it
1291    /// look supported.
1292    #[test]
1293    fn the_env_switch_is_honoured() {
1294        for off in ["off", "OFF", "0", "false", "no", " off "] {
1295            assert!(!enabled_from(off), "{off:?} should disable the rule");
1296        }
1297        for on in ["", "on", "1", "true", "anything-else"] {
1298            assert!(enabled_from(on), "{on:?} should leave the rule enabled");
1299        }
1300    }
1301
1302    /// DataFusion's own FD helper is what `optimize_projections` uses, and it
1303    /// stops at the first table: it keeps `n_name` in the key because nothing
1304    /// composes `c_custkey → c_nationkey = n_nationkey → n_name`. This records
1305    /// the gap the rule's own closure exists to fill, so a future DataFusion
1306    /// that closes it does not leave two mechanisms fighting.
1307    #[tokio::test]
1308    async fn datafusion_alone_does_not_reach_through_the_second_table() {
1309        let ctx = context(false, true);
1310        let plan = ctx.sql(Q10_SHAPE).await.unwrap().into_optimized_plan().unwrap();
1311        let agg = find_aggregate(&plan)
1312            .unwrap_or_else(|| panic!("no aggregate in:\n{}", plan.display_indent()));
1313        let names: Vec<String> = agg
1314            .group_expr
1315            .iter()
1316            .map(|e| e.schema_name().to_string())
1317            .collect();
1318        let minimal = get_required_group_by_exprs_indices(agg.input.schema(), &names)
1319            .expect("customer's declared key must be visible");
1320        let kept: Vec<&String> = minimal.iter().map(|i| &names[*i]).collect();
1321        assert!(
1322            kept.iter().any(|n| n.ends_with("n_name")),
1323            "DataFusion is expected to keep n_name; it kept {kept:?}"
1324        );
1325    }
1326
1327    /// The first `Aggregate` anywhere in a plan.
1328    fn find_aggregate(plan: &LogicalPlan) -> Option<&Aggregate> {
1329        if let LogicalPlan::Aggregate(agg) = plan {
1330            return Some(agg);
1331        }
1332        plan.inputs().into_iter().find_map(find_aggregate)
1333    }
1334}