Skip to content

Commit c4b2045

Browse files
MagicalTuxclaude
andcommitted
feat(planner): seek a trailing rowid range via a rowid/oid alias too (B9g)
Completes B9g: `WHERE b=? AND rowid>?` (and `_rowid_`/`oid`, and `rowid BETWEEN … AND …`) now bounds the `(b, rowid)` index range exactly as the INTEGER PRIMARY KEY column spelling does. A new `rowid_alias_range` collector walks the WHERE for a rowid-alias range (the column-name range collector resolves the IPK by its declared name, so the bare alias needed a separate pass); both the executor seek and eqp_access fall back to it when no IPK-column range is present. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f2077ab commit c4b2045

3 files changed

Lines changed: 135 additions & 68 deletions

File tree

ROADMAP.md

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1199,15 +1199,13 @@ tie/representative order), so they are perf/EQP-fidelity work, not correctness:
11991199
(always case-sensitive) into `col >= 'prefix' AND col < 'prefix⁺'` and seeks a
12001200
BINARY index; graphite scans. Must gate on the index's collation being BINARY (else
12011201
the EQP diverges on a NOCASE index) and handle the multi-byte prefix increment.
1202-
- **B9g — eq-prefix + trailing rowid range on a secondary index.** *Done* for the
1203-
INTEGER-PRIMARY-KEY-column spelling: `WHERE b=? AND a>?` (a the IPK) now seeks and
1204-
renders `SEARCH … USING INDEX ib (b=? AND rowid>?)`, bounding the `(b, rowid)` index
1205-
range directly — the rowid is the index's implicit trailing key. Extended the
1206-
existing eq-prefix + next-column range seek (executor `try_index_lookup` +
1207-
`eqp_access`, in lockstep). *Residual:* the bare `rowid`-alias spelling
1208-
(`… AND rowid>?`) still renders `(b=?)` because `collect_range_constraints` resolves
1209-
by column name and the IPK column is `a` (rows stay correct via the WHERE re-apply);
1210-
needs rowid-alias range collection.
1202+
- **B9g — eq-prefix + trailing rowid range on a secondary index. ✅ Done.**
1203+
`WHERE b=? AND a>?` (a the IPK) *and* the bare `rowid`/`_rowid_`/`oid` alias spelling
1204+
now seek and render `SEARCH … USING INDEX ib (b=? AND rowid>?)`, bounding the
1205+
`(b, rowid)` index range directly — the rowid is the index's implicit trailing key.
1206+
Extended the existing eq-prefix + next-column range seek (executor `try_index_lookup`
1207+
+ `eqp_access`, in lockstep) with a `next_pos == idx_cols.len()` rowid-tail block,
1208+
and added a `rowid_alias_range` collector for the alias spelling.
12111209
- **B9h — cost-model single-table index *choice*.** SQLite prefers, among indexes
12121210
that share an equality prefix, the one whose walk does the most work: a composite
12131211
`(b,c)` over `(b)` when a trailing range (`b=? AND c>?`) or a `GROUP BY`/`ORDER BY c`

src/exec/mod.rs

Lines changed: 123 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -11562,52 +11562,53 @@ impl Connection {
1156211562
// component of every secondary index entry, so `x=? AND rowid>?` bounds the
1156311563
// `(x, rowid)` range `[eq…, lo] .. [eq…, hi]` (SQLite renders it the same way).
1156411564
// Superset-safe — `run_core` re-applies the full `WHERE`.
11565-
if next_pos == idx_cols.len() {
11566-
if let Some(ipk) = meta.ipk {
11567-
let mut ranges: alloc::collections::BTreeMap<usize, RangeBound> =
11568-
alloc::collections::BTreeMap::new();
11569-
collect_range_constraints(where_expr, &meta.columns, params, &mut ranges);
11570-
if let Some(b) = ranges.get(&ipk) {
11571-
let mut colls = full_colls[..next_pos].to_vec();
11572-
colls.push(crate::value::Collation::default());
11573-
let mut lo_key = key.clone();
11574-
let lo_inc = match b.lower.as_ref() {
11575-
Some((v, inc)) => {
11576-
lo_key.push(v.clone());
11577-
*inc
11578-
}
11579-
None => true,
11580-
};
11581-
let mut hi_key = key.clone();
11582-
let hi_inc = match b.upper.as_ref() {
11583-
Some((v, inc)) => {
11584-
hi_key.push(v.clone());
11585-
*inc
11586-
}
11587-
None => true,
11588-
};
11589-
let rowids = crate::btree::index_range_rowids(
11590-
self.backend.source(),
11591-
root,
11592-
Some((lo_key.as_slice(), lo_inc)),
11593-
Some((hi_key.as_slice(), hi_inc)),
11594-
&colls,
11595-
)?;
11596-
let encoding = self.backend.source().header().text_encoding;
11597-
let mut cur = TableCursor::new(self.backend.source(), meta.root);
11598-
let mut out = Vec::new();
11599-
for rid in rowids {
11600-
if cur.seek(rid)? {
11601-
let values =
11602-
self.decode_full_row(meta, rid, &cur.payload()?, encoding)?;
11603-
out.push(InputRow {
11604-
values,
11605-
rowid: Some(rid),
11606-
});
11607-
}
11565+
if next_pos == idx_cols.len() && meta.ipk.is_some() {
11566+
let mut ranges: alloc::collections::BTreeMap<usize, RangeBound> =
11567+
alloc::collections::BTreeMap::new();
11568+
collect_range_constraints(where_expr, &meta.columns, params, &mut ranges);
11569+
let rowid_bound = meta
11570+
.ipk
11571+
.and_then(|ipk| ranges.remove(&ipk))
11572+
.or_else(|| rowid_alias_range(where_expr, meta, params));
11573+
if let Some(b) = rowid_bound {
11574+
let mut colls = full_colls[..next_pos].to_vec();
11575+
colls.push(crate::value::Collation::default());
11576+
let mut lo_key = key.clone();
11577+
let lo_inc = match b.lower.as_ref() {
11578+
Some((v, inc)) => {
11579+
lo_key.push(v.clone());
11580+
*inc
11581+
}
11582+
None => true,
11583+
};
11584+
let mut hi_key = key.clone();
11585+
let hi_inc = match b.upper.as_ref() {
11586+
Some((v, inc)) => {
11587+
hi_key.push(v.clone());
11588+
*inc
11589+
}
11590+
None => true,
11591+
};
11592+
let rowids = crate::btree::index_range_rowids(
11593+
self.backend.source(),
11594+
root,
11595+
Some((lo_key.as_slice(), lo_inc)),
11596+
Some((hi_key.as_slice(), hi_inc)),
11597+
&colls,
11598+
)?;
11599+
let encoding = self.backend.source().header().text_encoding;
11600+
let mut cur = TableCursor::new(self.backend.source(), meta.root);
11601+
let mut out = Vec::new();
11602+
for rid in rowids {
11603+
if cur.seek(rid)? {
11604+
let values = self.decode_full_row(meta, rid, &cur.payload()?, encoding)?;
11605+
out.push(InputRow {
11606+
values,
11607+
rowid: Some(rid),
11608+
});
1160811609
}
11609-
return Ok(Some(out));
1161011610
}
11611+
return Ok(Some(out));
1161111612
}
1161211613
}
1161311614

@@ -14815,18 +14816,20 @@ impl Connection {
1481514816
conds.push(alloc::format!("{name}<?"));
1481614817
}
1481714818
}
14818-
} else if matched.len() == idx_cols.len() {
14819-
if let Some(ipk) = meta.ipk {
14820-
let mut ranges: alloc::collections::BTreeMap<usize, RangeBound> =
14821-
alloc::collections::BTreeMap::new();
14822-
collect_range_constraints(where_expr, &meta.columns, params, &mut ranges);
14823-
if let Some(b) = ranges.get(&ipk) {
14824-
if b.lower.is_some() {
14825-
conds.push(String::from("rowid>?"));
14826-
}
14827-
if b.upper.is_some() {
14828-
conds.push(String::from("rowid<?"));
14829-
}
14819+
} else if matched.len() == idx_cols.len() && meta.ipk.is_some() {
14820+
let mut ranges: alloc::collections::BTreeMap<usize, RangeBound> =
14821+
alloc::collections::BTreeMap::new();
14822+
collect_range_constraints(where_expr, &meta.columns, params, &mut ranges);
14823+
let rowid_bound = meta
14824+
.ipk
14825+
.and_then(|ipk| ranges.remove(&ipk))
14826+
.or_else(|| rowid_alias_range(where_expr, meta, params));
14827+
if let Some(b) = rowid_bound {
14828+
if b.lower.is_some() {
14829+
conds.push(String::from("rowid>?"));
14830+
}
14831+
if b.upper.is_some() {
14832+
conds.push(String::from("rowid<?"));
1483014833
}
1483114834
}
1483214835
}
@@ -30407,6 +30410,70 @@ fn flip_cmp(op: BinaryOp) -> BinaryOp {
3040730410
}
3040830411
}
3040930412

30413+
/// A range on the table's rowid expressed through a `rowid`/`_rowid_`/`oid` alias
30414+
/// (`… AND rowid>?`) — the column-name range collector resolves the INTEGER PRIMARY
30415+
/// KEY by its declared name, so the bare-alias spelling needs this separate walk.
30416+
/// Returns the folded `RangeBound`, or `None` when no such bound is present. The
30417+
/// alias must not be shadowed by a real column of that name.
30418+
fn rowid_alias_range(e: &Expr, meta: &TableMeta, params: &Params) -> Option<RangeBound> {
30419+
fn is_rowid(x: &Expr, meta: &TableMeta) -> bool {
30420+
matches!(x, Expr::Column { column, .. }
30421+
if is_rowid_alias(column)
30422+
&& !meta.columns.iter().any(|c| c.name.eq_ignore_ascii_case(column)))
30423+
}
30424+
fn walk(e: &Expr, meta: &TableMeta, params: &Params, out: &mut RangeBound, found: &mut bool) {
30425+
match e {
30426+
Expr::Binary {
30427+
op: BinaryOp::And,
30428+
left,
30429+
right,
30430+
} => {
30431+
walk(left, meta, params, out, found);
30432+
walk(right, meta, params, out, found);
30433+
}
30434+
Expr::Paren(inner) => walk(inner, meta, params, out, found),
30435+
Expr::Binary { op, left, right }
30436+
if matches!(
30437+
op,
30438+
BinaryOp::Lt | BinaryOp::LtEq | BinaryOp::Gt | BinaryOp::GtEq
30439+
) =>
30440+
{
30441+
if is_rowid(left, meta) {
30442+
if let Some(v) = const_value(right, params) {
30443+
apply_bound(out, *op, v);
30444+
*found = true;
30445+
}
30446+
} else if is_rowid(right, meta) {
30447+
if let Some(v) = const_value(left, params) {
30448+
apply_bound(out, flip_cmp(*op), v);
30449+
*found = true;
30450+
}
30451+
}
30452+
}
30453+
Expr::Between {
30454+
expr,
30455+
low,
30456+
high,
30457+
negated: false,
30458+
} if is_rowid(expr, meta) => {
30459+
if let Some(v) = const_value(low, params) {
30460+
apply_bound(out, BinaryOp::GtEq, v);
30461+
*found = true;
30462+
}
30463+
if let Some(v) = const_value(high, params) {
30464+
apply_bound(out, BinaryOp::LtEq, v);
30465+
*found = true;
30466+
}
30467+
}
30468+
_ => {}
30469+
}
30470+
}
30471+
let mut b = RangeBound::default();
30472+
let mut found = false;
30473+
walk(e, meta, params, &mut b, &mut found);
30474+
found.then_some(b)
30475+
}
30476+
3041030477
/// Collect per-column range bounds (`<`/`<=`/`>`/`>=`/`BETWEEN`) from the
3041130478
/// top-level `AND` conjuncts of `WHERE`, keyed by column index. Drives an index
3041230479
/// range scan; non-range and non-constant terms are ignored (the full `WHERE` is

tests/seek_trailing_rowid_range.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@
33
//! `SEARCH t USING INDEX ib (b=? AND rowid>?)`, because every secondary-index entry
44
//! is keyed `(cols…, rowid)`. graphite used to render (and seek) only `(b=?)` and
55
//! re-filter the rowid bound; it now bounds the `(b, rowid)` range directly, matching
6-
//! SQLite. The rowid range is expressed via the INTEGER PRIMARY KEY column (`a`); the
7-
//! bare `rowid`-alias spelling still renders `(b=?)` in EQP (rows stay correct via the
8-
//! WHERE re-apply) — a small follow-up. Verified vs the sqlite3 3.50.4 CLI.
6+
//! SQLite. The rowid range is expressed via the INTEGER PRIMARY KEY column (`a`) or a
7+
//! bare `rowid`/`_rowid_`/`oid` alias. Verified vs the sqlite3 3.50.4 CLI.
98
109
#![cfg(feature = "std")]
1110

@@ -56,6 +55,9 @@ fn trailing_rowid_range_plan_matches_sqlite() {
5655
"SELECT * FROM t WHERE b=1 AND a>0 AND a<9",
5756
"SELECT * FROM t WHERE b=1 AND a<9",
5857
"SELECT * FROM t WHERE b=1 AND a>=3",
58+
"SELECT * FROM t WHERE b=1 AND rowid>5", // rowid alias
59+
"SELECT * FROM t WHERE b=1 AND oid<9", // oid alias
60+
"SELECT * FROM t WHERE b=1 AND rowid BETWEEN 2 AND 8",
5961
"SELECT a FROM t WHERE b=1 AND a>0", // covering
6062
// No rowid range → plain prefix seek, unchanged.
6163
"SELECT * FROM t WHERE b=1",

0 commit comments

Comments
 (0)