Skip to content

Commit 53b70fe

Browse files
MagicalTuxclaude
andcommitted
perf(planner): read a covering index for a no-seek WHERE scan
When a query's `WHERE` seeks no index (no equality/range on any index's leading column), SQLite still reads from a covering secondary index if one holds every column the query references — both projected and `WHERE`-tested — and is narrower than the table, filtering in the index (`SCAN t USING COVERING INDEX <ix>`) rather than scanning the table. graphite previously declined any covering scan once a `WHERE` was present. `covering_scan` now handles the `WHERE` case, gated so it never steals the `SEARCH` path: it applies only when `eqp_access` would render a bare `SCAN` (i.e. no seek). This keeps the plan renderer and the executor in lockstep — the EQP chain consults `covering_scan` before the seek renderer, while the executor reaches it only after its own seek attempts fail. Column coverage already includes the `WHERE` clause, and the same strictly-narrower width test applies. The covered scan's row order (index order) matches SQLite. Verified plan and rows against sqlite3 3.50.4 (`tests/eqp_covering_scan_where.rs`), including seekable `WHERE`s correctly staying `SEARCH`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e91ae0d commit 53b70fe

2 files changed

Lines changed: 83 additions & 1 deletion

File tree

src/exec/mod.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17895,12 +17895,28 @@ impl Connection {
1789517895
if t.subquery.is_some() || t.tvf_args.is_some() || t.schema.is_some() {
1789617896
return None;
1789717897
}
17898-
if sel.where_clause.is_some() || window::has_window(sel) || meta.without_rowid {
17898+
if window::has_window(sel) || meta.without_rowid {
1789917899
return None;
1790017900
}
1790117901
if self.lookup_cte(&t.name, None).is_some() || self.is_view(&t.name) {
1790217902
return None;
1790317903
}
17904+
// A `WHERE` that *seeks* an index is a `SEARCH`, owned by the `eqp_access`
17905+
// seek path (which runs after this in both the EQP chain and the executor).
17906+
// A covering *full* scan applies only when no seek does — `eqp_access`
17907+
// renders exactly a bare `SCAN {label}` in that case, so gate on it. (The
17908+
// executor reaches here only after its own seek attempts fail, so this keeps
17909+
// the two in lockstep.) A covering index must also hold every `WHERE` column,
17910+
// which `query_cols_covered` already checks below.
17911+
if let Some(w) = &sel.where_clause {
17912+
let label = t.alias.as_deref().unwrap_or(&t.name);
17913+
let acc = self
17914+
.eqp_access(label, &t.name, meta, Some(w), Some(sel), params)
17915+
.ok()?;
17916+
if acc != alloc::format!("SCAN {label}") {
17917+
return None;
17918+
}
17919+
}
1790417920
// If the ORDER BY is already satisfied by a scan's natural order — the
1790517921
// rowid order of a table scan (`rowid_ordered_scan`) or an index walk
1790617922
// (`order_index_scan`) — leave it alone. A covering scan reads in index

tests/eqp_covering_scan_where.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
//! A no-seek `WHERE` scan reads from a *covering* index when one index holds
2+
//! every column the query references (both projected and `WHERE`-tested) and is
3+
//! narrower than the table: SQLite reads and filters in the index
4+
//! (`SCAN t USING COVERING INDEX <ix>`) rather than the table. A `WHERE` that
5+
//! *seeks* an index (an equality/range on the index's leading column) is a
6+
//! `SEARCH` and is left to that path. Verified (plan and rows) vs sqlite3 3.50.4.
7+
8+
#![cfg(feature = "std")]
9+
10+
use std::process::Command;
11+
12+
fn sqlite3_available() -> bool {
13+
Command::new("sqlite3").arg("--version").output().is_ok()
14+
}
15+
16+
fn plan(bin: &str, base: &str, sql: &str) -> String {
17+
let full = format!("{base} EXPLAIN QUERY PLAN {sql}");
18+
let out = Command::new(bin).arg(":memory:").arg(&full).output().unwrap();
19+
String::from_utf8_lossy(&out.stdout)
20+
.lines()
21+
.filter(|l| !l.trim().is_empty() && !l.starts_with("QUERY PLAN"))
22+
.map(|l| l.trim_start_matches(|c: char| " |`*+_-".contains(c)))
23+
.collect::<Vec<_>>()
24+
.join("#")
25+
}
26+
27+
fn rows(bin: &str, base: &str, sql: &str) -> String {
28+
let out = Command::new(bin)
29+
.arg(":memory:")
30+
.arg(format!("{base} {sql}"))
31+
.output()
32+
.unwrap();
33+
String::from_utf8_lossy(&out.stdout).into_owned()
34+
}
35+
36+
const BASE: &str = "CREATE TABLE t(a INTEGER PRIMARY KEY, b, c, d, e);\
37+
CREATE INDEX tbc ON t(b,c); CREATE INDEX td ON t(d);\
38+
INSERT INTO t VALUES(1,5,50,7,'x'),(2,3,30,9,'y'),(3,9,90,8,'z');";
39+
40+
#[test]
41+
fn covering_scan_where_matches_sqlite() {
42+
if !sqlite3_available() {
43+
eprintln!("sqlite3 CLI not found; skipping");
44+
return;
45+
}
46+
let g = env!("CARGO_BIN_EXE_graphitesql");
47+
for q in [
48+
// Non-seekable WHERE fully covered by tbc → covering scan.
49+
"SELECT b FROM t WHERE c > 40",
50+
"SELECT b FROM t WHERE c = 50",
51+
"SELECT b, c FROM t WHERE c > 40",
52+
"SELECT b FROM t WHERE c BETWEEN 30 AND 60",
53+
"SELECT b FROM t WHERE c IN (30, 90)",
54+
"SELECT count(*) FROM t WHERE c > 40",
55+
"SELECT b FROM t WHERE c > 40 OR c < 10",
56+
// Seekable WHERE → SEARCH, not a covering full scan.
57+
"SELECT b FROM t WHERE b = 5",
58+
"SELECT b FROM t WHERE c > 40 AND b < 9",
59+
"SELECT b FROM t WHERE c > 40 AND d = 7",
60+
// Not covered (d in tbc? no) → plain scan.
61+
"SELECT b FROM t WHERE e > 0",
62+
] {
63+
assert_eq!(plan("sqlite3", BASE, q), plan(g, BASE, q), "plan `{q}`");
64+
assert_eq!(rows("sqlite3", BASE, q), rows(g, BASE, q), "rows `{q}`");
65+
}
66+
}

0 commit comments

Comments
 (0)