Skip to content

Commit a358e80

Browse files
MagicalTuxclaude
andcommitted
feat(eqp): flatten a bare-LIMIT derived/CTE body under a narrower projection or single-term ORDER BY (B9c-flatten)
Completes B9c. A bare-LIMIT body (no OFFSET) now flattens, not just under a pure-wildcard outer, but also: - a narrower projection with no outer WHERE — the outer projection is substituted into the LIMIT body (`SELECT a FROM (SELECT * FROM t LIMIT 5)` -> `SCAN t USING COVERING INDEX`), via a relaxed `inner_scan_flatten_ok` gate on the unified merge branch; - a single-term outer ORDER BY (no WHERE) — a dedicated branch pushes the projection + ORDER BY into the body and recurses (`(… LIMIT 5) ORDER BY b` -> `SCAN t USING INDEX tb`, or a full temp-b-tree when unindexed). A multi-term outer ORDER BY still declines: SQLite full-sorts the materialized LIMIT rows, whereas pushing it down would render a partial `LAST TERM` index walk when the leading prefix is indexed. The merge branch now borrows `derived_map` so it survives for the ORDER-BY branch. Existing flatten/co-routine taxonomy tests green; new render cases added and the narrower-over-LIMIT decline replaced with the multi-term-ORDER-BY decline. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 892ebe1 commit a358e80

3 files changed

Lines changed: 101 additions & 17 deletions

File tree

ROADMAP.md

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1192,16 +1192,18 @@ tie/representative order), so they are perf/EQP-fidelity work, not correctness:
11921192
the windowed input; the `(subquery-N)` label is codegen-order-fragile (see the
11931193
`schema-sql-canonicalization` note), so this needs a deterministic-numbering model
11941194
before it can be byte-exact. Rows already correct.
1195-
- **B9c — remaining derived/CTE `LIMIT`-body EQP paths.** The bare-`LIMIT` /
1196-
pure-wildcard flatten and the *non-flattenable* co-routine cases are now done: an
1197-
`OFFSET` body, an outer `WHERE`, or an outer aggregate over a `LIMIT` body
1198-
materializes as `CO-ROUTINE <name>` (body plan recursed) + the outer
1199-
`{SCAN|SEARCH} <name>`, via the same wrapper as an aggregate body. *Still open:* the
1200-
*flatten* variants SQLite applies — a **narrower projection** or an **outer
1201-
`ORDER BY`** over a bare-`LIMIT` body (`SELECT a FROM (SELECT * FROM t LIMIT 5)`
1202-
`SCAN t USING COVERING INDEX`) — which need the projection / ordering merged into the
1203-
`LIMIT` body; those still decline. Boundary rules in the `eqp-derived-coroutine`
1204-
memory.
1195+
- **B9c — remaining derived/CTE `LIMIT`-body EQP paths. ✅ Done.** The bare-`LIMIT`
1196+
pure-wildcard flatten, the *non-flattenable* co-routine cases (an `OFFSET` body, an
1197+
outer `WHERE`, or an outer aggregate over a `LIMIT` body → `CO-ROUTINE <name>` + the
1198+
outer `{SCAN|SEARCH} <name>`), AND the *flatten* variants are all done now: a
1199+
**narrower projection** over a bare-`LIMIT` body with no outer `WHERE` substitutes the
1200+
outer projection into the body (`SELECT a FROM (SELECT * FROM t LIMIT 5)` → `SCAN t
1201+
USING COVERING INDEX`), and a **single-term outer `ORDER BY`** pushes the projection +
1202+
ORDER BY into the body and recurses (`(… LIMIT 5) ORDER BY b` → `SCAN t USING INDEX
1203+
tb` / a full temp-b-tree if unindexed). *Residual:* a **multi-term** outer `ORDER BY`
1204+
over a `LIMIT` body still declines — SQLite full-sorts the materialized `LIMIT` rows,
1205+
whereas pushing the ORDER BY down would render a partial `LAST TERM` index walk when
1206+
the leading prefix is indexed. Boundary rules in the `eqp-derived-coroutine` memory.
12051207
- **B9d — `SEARCH` + `GROUP BY`/`DISTINCT` temp-b-tree node.** graphite emits the
12061208
grouping b-tree only over a bare `SCAN`; under a `WHERE` seek it omits the node.
12071209
Entangled with B9h (SQLite often picks a *different* index whose walk serves the

src/exec/mod.rs

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13861,6 +13861,16 @@ impl Connection {
1386113861
&& !sub.where_clause.as_ref().is_some_and(expr_has_subquery);
1386213862
let inner_is_base_table_scan =
1386313863
inner_base_scan_no_limit && sub.limit.is_none() && sub.offset.is_none();
13864+
// A bare `LIMIT` body (no `OFFSET`) also flattens under a *narrower*
13865+
// projection — SQLite substitutes the outer projection into the `LIMIT`
13866+
// body (`SELECT a FROM (SELECT * FROM t LIMIT 5)` → `SCAN t USING
13867+
// COVERING INDEX`). Only when the outer carries no `WHERE`: a predicate
13868+
// over a `LIMIT` body is filter-after-limit, which SQLite materializes
13869+
// as a `CO-ROUTINE` (handled below) rather than folding into the scan.
13870+
let inner_scan_flatten_ok = inner_is_base_table_scan
13871+
|| (inner_base_scan_no_limit
13872+
&& sub.offset.is_none()
13873+
&& sel.where_clause.is_none());
1386413874
// A pure-wildcard outer over a single base-table body recurses into the
1386513875
// body's own plan. A bare `LIMIT` body (no `OFFSET`) flattens the same
1386613876
// way — SQLite renders just the body's `SCAN`/index walk, the `LIMIT`
@@ -13969,24 +13979,24 @@ impl Connection {
1396913979
.is_none_or(|p| all_qualifiers_match(p, co_label));
1397013980
if let (true, true, true, Some(rename)) = (
1397113981
outer_general_flatten,
13972-
inner_is_base_table_scan,
13982+
inner_scan_flatten_ok,
1397313983
outer_where_qualifiers_ok && !(outer_is_wildcard && sel.where_clause.is_none()),
13974-
derived_map,
13984+
derived_map.as_ref(),
1397513985
) {
13976-
if outer_refs_resolve(&rename) {
13986+
if outer_refs_resolve(rename) {
1397713987
let mut merged = sub.clone();
1397813988
if !outer_is_wildcard {
1397913989
let mut cols = sel.columns.clone();
1398013990
for c in &mut cols {
1398113991
if let ResultColumn::Expr { expr, .. } = c {
13982-
rewrite_flattened_column(expr, co_label, &rename);
13992+
rewrite_flattened_column(expr, co_label, rename);
1398313993
}
1398413994
}
1398513995
merged.columns = cols;
1398613996
}
1398713997
if let Some(pred) = &sel.where_clause {
1398813998
let mut pred = pred.clone();
13989-
rewrite_flattened_column(&mut pred, co_label, &rename);
13999+
rewrite_flattened_column(&mut pred, co_label, rename);
1399014000
merged.where_clause = Some(match merged.where_clause.take() {
1399114001
Some(inner) => Expr::Binary {
1399214002
op: BinaryOp::And,
@@ -13999,6 +14009,67 @@ impl Connection {
1399914009
return self.eqp_select(&merged, parent, next_id, out, params);
1400014010
}
1400114011
}
14012+
// A bare-`LIMIT` body (no `OFFSET`) under an outer `ORDER BY` (and no
14013+
// `WHERE`) flattens: SQLite pushes the outer projection + `ORDER BY` into
14014+
// the flattened scan (`SELECT * FROM (SELECT * FROM t LIMIT 5) ORDER BY b`
14015+
// → `SCAN t USING INDEX tb`). We merge the outer projection + `ORDER BY`
14016+
// into the `LIMIT` body and recurse — the body's own `eqp_select` renders
14017+
// the ORDER-BY index walk / temp-b-tree. Same name-soundness gate as the
14018+
// projection merge, plus every `ORDER BY` column must name a source output
14019+
// (a positional term needs no rename).
14020+
// Restricted to a *single* ORDER BY term: a lone term is either fully
14021+
// served by an index walk (`SCAN … USING INDEX`) or fully unsorted
14022+
// (`SCAN … + USE TEMP B-TREE FOR ORDER BY`), both matching SQLite's outer
14023+
// plan; a multi-term ORDER BY whose leading prefix is indexed but tail is
14024+
// not would render a partial-sort `LAST TERM` here while SQLite full-sorts
14025+
// the materialized `LIMIT` rows — so multi-term declines.
14026+
if outer_flattenable_proj
14027+
&& sel.order_by.len() == 1
14028+
&& sel.where_clause.is_none()
14029+
&& sel.group_by.is_empty()
14030+
&& sel.having.is_none()
14031+
&& !sel.distinct
14032+
&& sel.compound.is_empty()
14033+
&& (from_cte || sel.ctes.is_empty())
14034+
&& inner_base_scan_no_limit
14035+
&& sub.limit.is_some()
14036+
&& sub.offset.is_none()
14037+
&& !sel.columns.iter().any(|c| match c {
14038+
ResultColumn::Expr { expr, .. } => expr_has_subquery(expr),
14039+
ResultColumn::Wildcard | ResultColumn::TableWildcard(_) => false,
14040+
})
14041+
&& !sel.order_by.iter().any(|t| expr_has_subquery(&t.expr))
14042+
&& sel
14043+
.order_by
14044+
.iter()
14045+
.all(|t| all_qualifiers_match(&t.expr, co_label))
14046+
{
14047+
if let Some(rename) = derived_map.as_ref() {
14048+
let names: Vec<String> = rename.iter().map(|(o, _)| o.clone()).collect();
14049+
let order_refs_ok = sel
14050+
.order_by
14051+
.iter()
14052+
.all(|t| all_column_names_in(&t.expr, &names));
14053+
if outer_refs_resolve(rename) && order_refs_ok {
14054+
let mut merged = sub.clone();
14055+
if !outer_is_wildcard {
14056+
let mut cols = sel.columns.clone();
14057+
for c in &mut cols {
14058+
if let ResultColumn::Expr { expr, .. } = c {
14059+
rewrite_flattened_column(expr, co_label, rename);
14060+
}
14061+
}
14062+
merged.columns = cols;
14063+
}
14064+
let mut order_by = sel.order_by.clone();
14065+
for t in &mut order_by {
14066+
rewrite_flattened_column(&mut t.expr, co_label, rename);
14067+
}
14068+
merged.order_by = order_by;
14069+
return self.eqp_select(&merged, parent, next_id, out, params);
14070+
}
14071+
}
14072+
}
1400214073
// A *compound* CTE/derived body that carries at least one dedup set
1400314074
// operator (`UNION` / `INTERSECT` / `EXCEPT`) cannot flatten into the
1400414075
// outer plan: SQLite materializes it as a `CO-ROUTINE <name>` whose

tests/eqp_derived_coroutine.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,14 @@ fn flattenable_wildcard_over_base_table_matches_sqlite() {
157157
"SELECT aa FROM (SELECT a AS aa FROM t) AS s WHERE aa<5",
158158
"SELECT * FROM (SELECT a AS aa FROM t) AS s WHERE aa>0",
159159
"SELECT s.aa FROM (SELECT a AS aa FROM t) AS s WHERE s.aa<5",
160+
// A bare-`LIMIT` body (no OFFSET) also flattens under a narrower projection
161+
// (no outer WHERE) and under a single-term outer ORDER BY (B9c-flatten).
162+
"SELECT a FROM (SELECT * FROM t LIMIT 5) AS s",
163+
"SELECT s.a FROM (SELECT * FROM t LIMIT 5) AS s",
164+
"WITH c AS (SELECT * FROM t LIMIT 5) SELECT a FROM c",
165+
"SELECT * FROM (SELECT * FROM t LIMIT 5) AS s ORDER BY a",
166+
"SELECT a FROM (SELECT * FROM t LIMIT 5) AS s ORDER BY a",
167+
"SELECT * FROM (SELECT * FROM t LIMIT 5) AS s ORDER BY b",
160168
] {
161169
let sql = format!("{base} EXPLAIN QUERY PLAN {q}");
162170
assert_eq!(run("sqlite3", &sql), run(g, &sql), "for {q}");
@@ -187,8 +195,11 @@ fn non_flattenable_outer_shapes_decline() {
187195
"SELECT b FROM (SELECT a AS aa FROM t) AS s", // outer ref not an output of the source
188196
"SELECT * FROM (SELECT * FROM t JOIN u ON t.a=u.x) AS s", // inner join
189197
"SELECT * FROM (SELECT * FROM v) AS s", // inner view
190-
"SELECT a FROM (SELECT * FROM t LIMIT 5) AS s", // narrower over a bare-LIMIT body (flatten TODO)
191-
"SELECT a FROM (SELECT * FROM t LIMIT 5) AS s", // narrower outer over a LIMIT body
198+
// A *multi-term* ORDER BY over a bare-LIMIT body still declines: SQLite
199+
// full-sorts the materialized LIMIT rows, but pushing the ORDER BY into the
200+
// body would render a partial index walk. (Single-term ORDER BY and a narrower
201+
// projection over a bare-LIMIT body DO flatten — see the render test.)
202+
"SELECT * FROM (SELECT * FROM t LIMIT 5) AS s ORDER BY a, b",
192203
] {
193204
let sql = format!("{base} EXPLAIN QUERY PLAN {q}");
194205
let got = run(g, &sql);

0 commit comments

Comments
 (0)