@@ -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).
0 commit comments