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}