Skip to content

Commit 36871e3

Browse files
MagicalTuxclaude
andcommitted
feat(vdbe): seek the inner side of an INNER rowid-join with a live cursor (B5b-2a)
The VDBE two-table INNER equi-join no longer materializes and scans the whole inner table when the `ON` binds the inner table's INTEGER PRIMARY KEY (`… JOIN t ON o.x = t.<ipk>`). Instead it scans only the outer table and, per outer row, seeks the single matching inner row through a live b-tree cursor (`read_row` → `TableCursor::seek`), reusing the tree-walker's own value→rowid coercion (`eval::to_i64`) so no new coercion semantics are introduced. The full `ON` is re-evaluated against the assembled row (the superset invariant), so every rowid-coercion corner (`= 2.5`, text/blob keys, `NULL`) filters exactly as the materialized cross-product would. Routing is parity-gated: any shape outside the guard (NATURAL/USING, non-base sources, non-ipk key, ambiguous columns, or a projection the single-cursor compiler can't take) falls through to the existing materialized path, and the VDBE returns `Unsupported` → tree-walker fallback, so correctness cannot regress. Verified VDBE-vs-tree-walker and vs sqlite3 3.50.4 (`tests/vdbe_live_cursor.rs`). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8e0e965 commit 36871e3

3 files changed

Lines changed: 357 additions & 7 deletions

File tree

ROADMAP.md

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1169,12 +1169,30 @@ gated on VDBE-vs-tree-walker parity, so it can't regress correctness):
11691169
- **B5c-2b** — compile `Subquery`/`Exists`/`InSelect` that read an outer column.
11701170
- **B5b-2 — seek-driven inner cursor over real storage (the big B8 step).** The
11711171
nested-loop join currently materializes each table's rows into in-memory
1172-
row-sets. This step gives the VDBE interpreter **live storage cursors**
1173-
(`OpenRead` + a b-tree `TableCursor`) so the inner side is *seeked* by
1174-
rowid/PK/index instead of materialized — mirroring the tree-walker's inner-join
1175-
seeks. B5b-1's multi-cursor opcodes (`RewindC`/`ColumnC`/`NextC`) are the
1176-
foundation. This is the largest remaining VDBE piece and the prerequisite that
1177-
makes correlated-subquery (B5c-2) and window streaming worthwhile.
1172+
row-sets. This step gives the join a **live storage cursor** so the inner side
1173+
is *seeked* by rowid/PK/index instead of materialized — mirroring the
1174+
tree-walker's inner-join seeks. This is the largest remaining VDBE piece and the
1175+
prerequisite that makes correlated-subquery (B5c-2) and window streaming
1176+
worthwhile.
1177+
- **B5b-2a — INNER-join inner rowid seek (INTEGER PRIMARY KEY). DONE.** A
1178+
two-table `… JOIN t ON o.x = t.<ipk>` now seeks the single matching inner row
1179+
with a *live* b-tree cursor (`read_row``TableCursor::seek`) instead of
1180+
scanning + materializing the whole inner table; only the outer table is
1181+
scanned. Modelled on the outer-join *row-assembly* path (build the joined rows
1182+
in Rust, then reuse `compile_table_select`/`run_rows` for projection / WHERE /
1183+
ORDER BY / GROUP BY / DISTINCT / LIMIT). Correctness rides the superset
1184+
invariant: after the seek the full `ON` is re-evaluated against the assembled
1185+
row, so every rowid-coercion corner (`= 2.5`, text/blob keys, `NULL`, duplicate
1186+
outer keys) is filtered exactly as the materialized cross-product would.
1187+
Narrowly routed (single INNER `Eq`-on-ipk, both plain base tables, ipk by its
1188+
declared column name — a bare `rowid` alias / compound `ON` / derived source
1189+
defers); any unhandled projection falls through to the materialized path.
1190+
Verified VDBE-vs-tree-walker and vs sqlite3 3.50.4 (`tests/vdbe_live_cursor.rs`).
1191+
- **Remaining:** in-*interpreter* `OpenRead`/`SeekRowid` opcodes over B5b-1's
1192+
multi-cursor foundation (so the seek lives in bytecode, not row-assembly);
1193+
seek by a **secondary index** / `WITHOUT ROWID` PK; the bare-`rowid`-alias `ON`
1194+
spelling (blocked on a pre-existing divergence — `t.rowid` in a join projection
1195+
already errors on the tree-walker); N-table chains.
11781196
- **B1c — RIGHT/FULL join inner seeks.** INNER/LEFT already seek; RIGHT/FULL still
11791197
materialize the inner table.
11801198

@@ -1452,7 +1470,10 @@ reasonable order:
14521470
1. **B5b-2 — live storage cursors on the VDBE.** The largest remaining VDBE piece;
14531471
it turns the materialized inner join into a seek-driven one and is the
14541472
prerequisite for streaming correlated subqueries and windows. Perf/coverage,
1455-
parity-gated, low risk.
1473+
parity-gated, low risk. **B5b-2a landed** (INNER-join inner rowid seek on an
1474+
INTEGER PRIMARY KEY — live `TableCursor::seek`, no inner materialization);
1475+
remaining sub-steps (in-interpreter seek opcodes, secondary-index / WITHOUT
1476+
ROWID seeks, N-table chains) tracked under §4's B5b-2 entry.
14561477
2. **B5c-2 — correlated subqueries on the VDBE**, once B5b-2 lands the live-cursor
14571478
machinery.
14581479
3. **C9a → C9d — the concurrency story** (persistent read locks in `src/pager/`,

src/exec/mod.rs

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1989,6 +1989,236 @@ impl Connection {
19891989
// the `WHERE`, and reuse the single-cursor scan compiler. Every join must
19901990
// be a plain `INNER`/`CROSS`/comma join (no `NATURAL`/`USING`/outer).
19911991
if !from.joins.is_empty() {
1992+
// B5b-2 (live inner cursor): a two-table INNER equi-join whose `ON`
1993+
// binds the inner table's INTEGER PRIMARY KEY (`… JOIN t ON o.x =
1994+
// t.<ipk>`) seeks the single matching inner row with a *live* b-tree
1995+
// cursor (`read_row` → `TableCursor::seek`) instead of materializing
1996+
// and scanning the whole inner table. Only the outer table is scanned;
1997+
// for each outer row the inner row is fetched by rowid. Correctness
1998+
// rides the superset invariant: after the seek the full `ON` is
1999+
// re-evaluated against the assembled row, so every rowid-coercion
2000+
// corner (`= 2.5`, text/blob keys, `NULL`) is filtered exactly as the
2001+
// materialized cross-product would. Any shape outside this narrow
2002+
// window — or a projection the single-cursor compiler can't take —
2003+
// breaks out and falls through to the materialized inner-join path.
2004+
'seek: {
2005+
if !(from.joins.len() == 1
2006+
&& from.joins[0].kind == sql::ast::JoinKind::Inner
2007+
&& !from.joins[0].natural
2008+
&& from.joins[0].using.is_empty())
2009+
{
2010+
break 'seek;
2011+
}
2012+
let j = &from.joins[0];
2013+
// Both sides must be plain base tables: the inner side needs a live
2014+
// rowid b-tree, and a CTE/view/subquery/TVF has none.
2015+
let plain = |tr: &sql::ast::TableRef| -> bool {
2016+
tr.subquery.is_none()
2017+
&& tr.tvf_args.is_none()
2018+
&& tr.schema.is_none()
2019+
&& !self.is_bare_tvf(tr)
2020+
};
2021+
if !plain(&from.first) || !plain(&j.table) {
2022+
break 'seek;
2023+
}
2024+
// The inner name must resolve to a real rowid table with an
2025+
// INTEGER PRIMARY KEY (a same-named CTE/view shadows a base table).
2026+
if self.is_view(&j.table.name)
2027+
|| sel
2028+
.ctes
2029+
.iter()
2030+
.any(|c| c.name.eq_ignore_ascii_case(&j.table.name))
2031+
{
2032+
break 'seek;
2033+
}
2034+
let inner_meta = match self.table_meta(&j.table.name, j.table.alias.as_deref()) {
2035+
Ok(m) => m,
2036+
Err(_) => break 'seek,
2037+
};
2038+
if inner_meta.without_rowid {
2039+
break 'seek;
2040+
}
2041+
let Some(ipk) = inner_meta.ipk else {
2042+
break 'seek;
2043+
};
2044+
let Some(on) = &j.on else { break 'seek };
2045+
// Outer schema + rows (the inner side is NOT scanned).
2046+
let (o_cols, o_tables, o_aff, o_coll, o_rows, _o_ids) = scan_one(&from.first)?;
2047+
// Inner schema — column metadata only, no row scan.
2048+
let inner_qual = j
2049+
.table
2050+
.alias
2051+
.clone()
2052+
.unwrap_or_else(|| j.table.name.clone());
2053+
let i_cols: Vec<String> =
2054+
inner_meta.columns.iter().map(|c| c.name.clone()).collect();
2055+
let i_coll: Vec<crate::value::Collation> =
2056+
inner_meta.columns.iter().map(|c| c.collation).collect();
2057+
let i_aff: Vec<eval::Affinity> =
2058+
inner_meta.columns.iter().map(|c| c.affinity).collect();
2059+
let i_tables: Vec<String> = inner_meta
2060+
.columns
2061+
.iter()
2062+
.map(|_| inner_qual.clone())
2063+
.collect();
2064+
let ipk_name = inner_meta.columns[ipk].name.clone();
2065+
// The `ON` must be a single `Eq` (parens stripped) binding the
2066+
// inner ipk column on one side and a plain outer column on the
2067+
// other.
2068+
let mut on_expr = on;
2069+
while let sql::ast::Expr::Paren(inner) = on_expr {
2070+
on_expr = inner;
2071+
}
2072+
let (l, r) = match on_expr {
2073+
sql::ast::Expr::Binary {
2074+
op: sql::ast::BinaryOp::Eq,
2075+
left,
2076+
right,
2077+
} => (left.as_ref(), right.as_ref()),
2078+
_ => break 'seek,
2079+
};
2080+
// A plain (optionally parenthesized) unqualified-schema column ref.
2081+
fn col_ref(mut e: &sql::ast::Expr) -> Option<(Option<&str>, &str)> {
2082+
while let sql::ast::Expr::Paren(i) = e {
2083+
e = i;
2084+
}
2085+
match e {
2086+
sql::ast::Expr::Column {
2087+
schema: None,
2088+
table,
2089+
column,
2090+
..
2091+
} => Some((table.as_deref(), column.as_str())),
2092+
_ => None,
2093+
}
2094+
}
2095+
// Is `e` the inner ipk column, named by its declared column name?
2096+
// (A bare `rowid`/`_rowid_`/`oid` alias defers — the combined
2097+
// schema exposes the ipk under its declared name, so the `ON`
2098+
// re-check below could not resolve the alias.) Qualified with the
2099+
// inner qualifier, or bare and not also an outer column name.
2100+
let is_inner_ipk = |e: &sql::ast::Expr| -> bool {
2101+
let Some((q, name)) = col_ref(e) else {
2102+
return false;
2103+
};
2104+
if !name.eq_ignore_ascii_case(&ipk_name) {
2105+
return false;
2106+
}
2107+
match q {
2108+
Some(q) => q.eq_ignore_ascii_case(&inner_qual),
2109+
None => !o_cols.iter().any(|c| c.eq_ignore_ascii_case(name)),
2110+
}
2111+
};
2112+
// Resolve `e` to a single OUTER column index (qualified to the
2113+
// outer table, or bare-unambiguous across both tables).
2114+
let outer_qual = from
2115+
.first
2116+
.alias
2117+
.clone()
2118+
.unwrap_or_else(|| from.first.name.clone());
2119+
let outer_col_index = |e: &sql::ast::Expr| -> Option<usize> {
2120+
let (q, name) = col_ref(e)?;
2121+
if let Some(q) = q {
2122+
if !q.eq_ignore_ascii_case(&outer_qual) {
2123+
return None;
2124+
}
2125+
}
2126+
let matches: Vec<usize> = o_cols
2127+
.iter()
2128+
.enumerate()
2129+
.filter(|(_, c)| c.eq_ignore_ascii_case(name))
2130+
.map(|(i, _)| i)
2131+
.collect();
2132+
if matches.len() != 1 {
2133+
return None;
2134+
}
2135+
// A bare name owned by both tables is ambiguous → defer.
2136+
if q.is_none() && i_cols.iter().any(|c| c.eq_ignore_ascii_case(name)) {
2137+
return None;
2138+
}
2139+
Some(matches[0])
2140+
};
2141+
let oc = if is_inner_ipk(l) {
2142+
match outer_col_index(r) {
2143+
Some(oc) => oc,
2144+
None => break 'seek,
2145+
}
2146+
} else if is_inner_ipk(r) {
2147+
match outer_col_index(l) {
2148+
Some(oc) => oc,
2149+
None => break 'seek,
2150+
}
2151+
} else {
2152+
break 'seek;
2153+
};
2154+
// Combined schema (outer cols ++ inner cols), leftmost outermost.
2155+
let mut c_cols = o_cols.clone();
2156+
c_cols.extend(i_cols.iter().cloned());
2157+
let mut c_tables = o_tables.clone();
2158+
c_tables.extend(i_tables.iter().cloned());
2159+
let mut c_aff = o_aff.clone();
2160+
c_aff.extend(i_aff.iter().copied());
2161+
let mut c_coll = o_coll.clone();
2162+
c_coll.extend(i_coll.iter().copied());
2163+
let join_cols: Vec<ColumnInfo> = (0..c_cols.len())
2164+
.map(|i| ColumnInfo {
2165+
name: c_cols[i].clone(),
2166+
table: c_tables[i].clone(),
2167+
affinity: c_aff[i],
2168+
collation: c_coll[i],
2169+
schema: None,
2170+
hidden: false,
2171+
})
2172+
.collect();
2173+
// An ambiguous bare column in the projection/WHERE defers so the
2174+
// tree-walker can reject it with "ambiguous column name".
2175+
if validate_unambiguous_columns(sel, &join_cols, &|t| t.into()).is_err() {
2176+
break 'seek;
2177+
}
2178+
// Compile the projection/WHERE/ORDER BY/… over the assembled join
2179+
// rows. The `ON` is applied during assembly, so the original `sel`
2180+
// (not an ON-merged WHERE) is compiled — exactly like the
2181+
// outer-join assembly path. A shape the single-cursor compiler
2182+
// can't take falls through to the materialized path.
2183+
let prog = match vdbe::compile_table_select(
2184+
sel, &c_cols, &c_tables, &c_aff, &c_coll, false,
2185+
) {
2186+
Ok(p) => p,
2187+
Err(Error::Unsupported(_)) => break 'seek,
2188+
Err(e) => return Err(e),
2189+
};
2190+
// Assemble the joined rows by seeking the inner table live, per
2191+
// outer row: coerce the outer key to a rowid (the same `to_i64`
2192+
// the tree-walker's rowid seek uses), seek it, then re-check the
2193+
// full `ON` so the result is a subset of the true cross-product.
2194+
let on_params = eval::Params::default();
2195+
let mut rows: Vec<Vec<Value>> = Vec::new();
2196+
for orow in &o_rows {
2197+
let kv = &orow[oc];
2198+
if matches!(kv, Value::Null) {
2199+
continue;
2200+
}
2201+
let rid = eval::to_i64(kv);
2202+
let Some(ivals) = self.read_row(&inner_meta, rid)? else {
2203+
continue;
2204+
};
2205+
let mut row = orow.clone();
2206+
row.extend(ivals.iter().cloned());
2207+
let ir = InputRow {
2208+
values: row.clone(),
2209+
rowid: None,
2210+
};
2211+
let ctx = ir.ctx(&join_cols, &on_params).with_subqueries(self);
2212+
if eval::truth(&eval::eval(on_expr, &ctx)?) == Some(true) {
2213+
rows.push(row);
2214+
}
2215+
}
2216+
let result = vdbe::run_rows(&prog, &rows)?;
2217+
return Ok(QueryResult {
2218+
columns: prog.columns,
2219+
rows: result,
2220+
});
2221+
}
19922222
// A single two-table LEFT/RIGHT/FULL JOIN routes to the null-padding
19932223
// nested loop below; otherwise only plain INNER joins are handled here
19942224
// (NATURAL/USING fall back to the tree-walker).

tests/vdbe_live_cursor.rs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
//! B5b-2 (live inner cursor): a two-table INNER equi-join whose `ON` binds the
2+
//! inner table's INTEGER PRIMARY KEY (`… JOIN t ON o.x = t.<ipk>`) seeks the
3+
//! single matching inner row with a *live* b-tree cursor (`TableCursor::seek`)
4+
//! instead of materializing and scanning the whole inner table. Only the outer
5+
//! table is scanned; each outer row fetches its one inner row by rowid.
6+
//!
7+
//! `query_vdbe` errors on any fallback to the tree-walker, so a passing
8+
//! `query_vdbe` proves the query routed onto this VDBE path. Correctness rides
9+
//! the superset invariant: after the seek the full `ON` is re-evaluated against
10+
//! the assembled row, so every rowid-coercion corner (`= 2.5`, text/blob keys,
11+
//! `NULL`) is filtered exactly as the materialized cross-product would. Each
12+
//! query is checked three ways: it routes on the VDBE, it equals the tree-walker
13+
//! (`set_use_vdbe(false)`), and it equals the sqlite3 CLI.
14+
15+
#![cfg(feature = "std")]
16+
17+
use graphitesql::Connection;
18+
use std::process::Command;
19+
20+
fn sqlite3_available() -> bool {
21+
Command::new("sqlite3").arg("--version").output().is_ok()
22+
}
23+
24+
const SCHEMA: &str = "CREATE TABLE o(x, y TEXT);\
25+
INSERT INTO o VALUES(1,'a'),(2,'b'),(3,'c'),(NULL,'n'),(2,'b2'),(2.0,'real'),\
26+
('2','txt'),('2abc','junk'),(x'32','blob'),(2.5,'half'),(99,'miss'),(-1,'neg');\
27+
CREATE TABLE t(id INTEGER PRIMARY KEY, name TEXT);\
28+
INSERT INTO t VALUES(1,'one'),(2,'two'),(5,'five');";
29+
30+
/// Queries that must route onto the live-inner-cursor path (`o JOIN t ON … =
31+
/// t.id`, with the inner table's INTEGER PRIMARY KEY on one side).
32+
const VDBE_QUERIES: &[&str] = &[
33+
"SELECT o.x, t.name FROM o JOIN t ON o.x = t.id ORDER BY o.y",
34+
"SELECT o.x, t.name FROM o JOIN t ON t.id = o.x ORDER BY o.y", // ipk on the left
35+
"SELECT o.y, t.name FROM o JOIN t ON o.x = t.id WHERE t.name <> 'one' ORDER BY o.y",
36+
"SELECT * FROM o JOIN t ON o.x = t.id ORDER BY o.y",
37+
"SELECT o.x * 100 + t.id AS s FROM o JOIN t ON o.x = t.id ORDER BY s",
38+
"SELECT DISTINCT t.name FROM o JOIN t ON o.x = t.id ORDER BY t.name",
39+
"SELECT o.x, t.name FROM o JOIN t ON o.x = t.id ORDER BY o.y LIMIT 2 OFFSET 1",
40+
"SELECT count(*), sum(t.id) FROM o JOIN t ON o.x = t.id",
41+
"SELECT t.name, count(*) FROM o JOIN t ON o.x = t.id GROUP BY t.name ORDER BY t.name",
42+
"SELECT o.y FROM o JOIN t ON (o.x) = (t.id) ORDER BY o.y", // parenthesized ON
43+
];
44+
45+
fn setup() -> Connection {
46+
let mut c = Connection::open_memory().unwrap();
47+
for stmt in SCHEMA.split(';') {
48+
if !stmt.trim().is_empty() {
49+
c.execute(stmt).unwrap();
50+
}
51+
}
52+
c
53+
}
54+
55+
#[test]
56+
fn live_inner_cursor_routes_and_matches_tree_walker() {
57+
let c = setup();
58+
for q in VDBE_QUERIES {
59+
// Routes onto the VDBE (errors on any fallback).
60+
let vdbe = c
61+
.query_vdbe(q)
62+
.unwrap_or_else(|e| panic!("expected VDBE routing for `{q}`: {e:?}"));
63+
// Tree-walker (source of truth).
64+
c.set_use_vdbe(false);
65+
let tw = c.query(q).unwrap();
66+
c.set_use_vdbe(true);
67+
assert_eq!(vdbe.rows, tw.rows, "VDBE vs tree-walker rows for `{q}`");
68+
assert_eq!(vdbe.columns, tw.columns, "columns for `{q}`");
69+
}
70+
}
71+
72+
fn cli_rows(bin: &str, sql: &str) -> String {
73+
let full = format!("{SCHEMA} {sql}");
74+
let out = Command::new(bin)
75+
.arg(":memory:")
76+
.arg(&full)
77+
.output()
78+
.unwrap();
79+
String::from_utf8_lossy(&out.stdout).into_owned()
80+
}
81+
82+
#[test]
83+
fn live_inner_cursor_matches_sqlite3() {
84+
if !sqlite3_available() {
85+
eprintln!("sqlite3 CLI not found; skipping");
86+
return;
87+
}
88+
// Compare the graphite CLI binary (VDBE enabled by default, so the seek path
89+
// runs) against sqlite3 end-to-end — float/blob rendering included, so no
90+
// hand-rolled value formatting can mask a divergence.
91+
let g = env!("CARGO_BIN_EXE_graphitesql");
92+
for q in VDBE_QUERIES {
93+
assert_eq!(
94+
cli_rows("sqlite3", q),
95+
cli_rows(g, q),
96+
"sqlite3 vs graphite `{q}`"
97+
);
98+
}
99+
}

0 commit comments

Comments
 (0)