Skip to content

Commit c22f251

Browse files
MagicalTuxclaude
andcommitted
feat(planner): seek a fixed-prefix GLOB range on a BINARY index (B9f)
`col GLOB 'abc*'` now seeks the `[abc, abd)` byte range on a BINARY index and renders `SEARCH t USING INDEX tb (b>? AND b<?)` instead of scanning. SQLite's GLOB is always case-sensitive and byte-based, so the index must be BINARY — the seek is gated on the column's collation, matching sqlite (which scans a NOCASE index). Implemented as a `BinaryOp::Glob` arm in the shared collect_range_constraints, so the executor range seek and the eqp_access label move in lockstep; the new glob_prefix_range helper extracts the literal prefix (up to the first `*`/`?`/`[`) and forms the exclusive upper bound by incrementing the last byte (dropping trailing 0xFF; a non-UTF-8 increment yields a lower-bound-only seek). The full GLOB is re-applied, so results are exact. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a4a6a97 commit c22f251

3 files changed

Lines changed: 162 additions & 4 deletions

File tree

ROADMAP.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1200,10 +1200,15 @@ tie/representative order), so they are perf/EQP-fidelity work, not correctness:
12001200
computed value in SQLite (`SEARCH … (b=?)`); graphite `SCAN`s because the eq-
12011201
collector requires a constant RHS. Needs the executor to evaluate the
12021202
non-correlated subquery once, then seek (superset-safe; `eqp_access` mirrors it).
1203-
- **B9f — `GLOB 'prefix*'` prefix-range seek.** SQLite rewrites a fixed-prefix `GLOB`
1204-
(always case-sensitive) into `col >= 'prefix' AND col < 'prefix⁺'` and seeks a
1205-
BINARY index; graphite scans. Must gate on the index's collation being BINARY (else
1206-
the EQP diverges on a NOCASE index) and handle the multi-byte prefix increment.
1203+
- **B9f — `GLOB 'prefix*'` prefix-range seek. ✅ Done.** A fixed-prefix `GLOB`
1204+
(always case-sensitive / byte-based) now seeks `col >= 'prefix' AND col < 'prefix⁺'`
1205+
on a BINARY index and reads `SEARCH … (b>? AND b<?)`. Implemented as a `BinaryOp::Glob`
1206+
arm in the shared `collect_range_constraints` (so the executor range seek and
1207+
`eqp_access` move in lockstep), gated on the column's collation being BINARY; the
1208+
`glob_prefix_range` helper extracts the literal prefix (up to the first `*`/`?`/`[`)
1209+
and increments the last byte `< 0xFF` (dropping trailing `0xFF`; a non-UTF-8
1210+
increment drops the upper bound → still a valid superset). A leading wildcard scans;
1211+
the full GLOB is re-applied so results are exact.
12071212
- **B9g — eq-prefix + trailing rowid range on a secondary index. ✅ Done.**
12081213
`WHERE b=? AND a>?` (a the IPK) *and* the bare `rowid`/`_rowid_`/`oid` alias spelling
12091214
now seek and render `SEARCH … USING INDEX ib (b=? AND rowid>?)`, bounding the

src/exec/mod.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30465,6 +30465,42 @@ fn flip_cmp(op: BinaryOp) -> BinaryOp {
3046530465
}
3046630466
}
3046730467

30468+
/// The `[lo, hi)` byte-range a fixed-prefix `GLOB` pattern seeks: `'abc*'` matches
30469+
/// exactly the strings `>= 'abc'` and `< 'abd'`. The literal prefix is the run before
30470+
/// the first GLOB metacharacter (`*`, `?`, `[`); an empty prefix (a leading wildcard)
30471+
/// is unseekable → `None`. The upper bound increments the last byte `< 0xFF` and drops
30472+
/// trailing `0xFF` bytes (so it dominates every string starting with the prefix); if
30473+
/// every byte is `0xFF`, or the increment is not valid UTF-8, there is no upper bound
30474+
/// (`hi = None`) and the seek runs from `lo` to the end — still a valid superset.
30475+
fn glob_prefix_range(pat: &str) -> Option<(String, Option<String>)> {
30476+
let prefix: String = pat
30477+
.chars()
30478+
.take_while(|&c| c != '*' && c != '?' && c != '[')
30479+
.collect();
30480+
if prefix.is_empty() {
30481+
return None;
30482+
}
30483+
let mut hi = prefix.clone().into_bytes();
30484+
loop {
30485+
match hi.last().copied() {
30486+
Some(0xFF) => {
30487+
hi.pop();
30488+
}
30489+
Some(b) => {
30490+
*hi.last_mut().unwrap() = b + 1;
30491+
break;
30492+
}
30493+
None => break,
30494+
}
30495+
}
30496+
let hi = if hi.is_empty() {
30497+
None
30498+
} else {
30499+
String::from_utf8(hi).ok()
30500+
};
30501+
Some((prefix, hi))
30502+
}
30503+
3046830504
/// A range on the table's rowid expressed through a `rowid`/`_rowid_`/`oid` alias
3046930505
/// (`… AND rowid>?`) — the column-name range collector resolves the INTEGER PRIMARY
3047030506
/// KEY by its declared name, so the bare-alias spelling needs this separate walk.
@@ -30592,6 +30628,29 @@ fn collect_range_constraints(
3059230628
}
3059330629
}
3059430630
}
30631+
// `col GLOB 'prefix*'` (SQLite's GLOB is always case-sensitive / byte-based)
30632+
// seeks the `[prefix, prefix⁺)` range on a BINARY index — the index orders keys
30633+
// by byte, matching GLOB. A NOCASE/RTRIM column can't serve it, so gate on the
30634+
// column's collation being BINARY. Superset-safe: `run_core` re-applies GLOB.
30635+
Expr::Binary {
30636+
op: BinaryOp::Glob,
30637+
left,
30638+
right,
30639+
} => {
30640+
if let (Some(ci), Some(Value::Text(pat))) =
30641+
(col_index(left, columns), const_value(right, params))
30642+
{
30643+
if columns[ci].collation == crate::value::Collation::Binary {
30644+
if let Some((lo, hi)) = glob_prefix_range(&pat) {
30645+
let b = out.entry(ci).or_default();
30646+
apply_bound(b, BinaryOp::GtEq, Value::Text(lo));
30647+
if let Some(hi) = hi {
30648+
apply_bound(b, BinaryOp::Lt, Value::Text(hi));
30649+
}
30650+
}
30651+
}
30652+
}
30653+
}
3059530654
_ => {}
3059630655
}
3059730656
}

tests/seek_glob_prefix.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
//! B9f — a fixed-prefix `GLOB` pattern seeks a byte range on a BINARY index. SQLite's
2+
//! `GLOB` is always case-sensitive and byte-based, so `b GLOB 'abc*'` matches exactly
3+
//! `b >= 'abc' AND b < 'abd'` and reads `SEARCH t USING INDEX tb (b>? AND b<?)`; a
4+
//! leading wildcard (`'*abc'`, `'[ab]c'`) has no seekable prefix and scans. The seek
5+
//! only applies to a BINARY-collation column (a NOCASE index orders keys differently),
6+
//! and the full `GLOB` is re-applied so results are exact. graphite used to scan every
7+
//! `GLOB`. Verified vs the sqlite3 3.50.4 CLI.
8+
9+
#![cfg(feature = "std")]
10+
11+
use std::process::Command;
12+
13+
fn sqlite3_available() -> bool {
14+
Command::new("sqlite3").arg("--version").output().is_ok()
15+
}
16+
17+
fn plan(bin: &str, base: &str, sql: &str) -> String {
18+
let full = format!("{base} EXPLAIN QUERY PLAN {sql}");
19+
let out = Command::new(bin)
20+
.arg(":memory:")
21+
.arg(&full)
22+
.output()
23+
.unwrap();
24+
String::from_utf8_lossy(&out.stdout)
25+
.lines()
26+
.filter(|l| !l.trim().is_empty() && !l.starts_with("QUERY PLAN"))
27+
.map(|l| l.trim_start_matches(|c: char| " |`*+_-".contains(c)))
28+
.collect::<Vec<_>>()
29+
.join("#")
30+
}
31+
32+
fn rows(bin: &str, base: &str, sql: &str) -> String {
33+
let full = format!("{base} {sql}");
34+
let out = Command::new(bin)
35+
.arg(":memory:")
36+
.arg(&full)
37+
.output()
38+
.unwrap();
39+
String::from_utf8_lossy(&out.stdout).trim_end().to_string()
40+
}
41+
42+
const BINARY_COL: &str =
43+
"CREATE TABLE t(a INTEGER PRIMARY KEY, b TEXT, c); CREATE INDEX tb ON t(b);";
44+
const NOCASE_COL: &str =
45+
"CREATE TABLE t(a INTEGER PRIMARY KEY, b TEXT COLLATE NOCASE, c); CREATE INDEX tb ON t(b);";
46+
47+
#[test]
48+
fn glob_prefix_plan_matches_sqlite() {
49+
if !sqlite3_available() {
50+
eprintln!("sqlite3 CLI not found; skipping");
51+
return;
52+
}
53+
let g = env!("CARGO_BIN_EXE_graphitesql");
54+
for q in [
55+
"SELECT * FROM t WHERE b GLOB 'abc*'", // prefix + wildcard → range seek
56+
"SELECT * FROM t WHERE b GLOB 'abc'", // no wildcard → still a prefix range
57+
"SELECT * FROM t WHERE b GLOB 'a?c*'", // '?' stops the prefix at 'a'
58+
"SELECT * FROM t WHERE b GLOB '*abc'", // leading wildcard → no prefix → SCAN
59+
"SELECT * FROM t WHERE b GLOB '[ab]c'", // char class → no prefix → SCAN
60+
] {
61+
assert_eq!(
62+
plan("sqlite3", BINARY_COL, q),
63+
plan(g, BINARY_COL, q),
64+
"plan for {q}"
65+
);
66+
}
67+
// A NOCASE-collation column can't serve the byte-ordered GLOB range → SCAN.
68+
assert_eq!(
69+
plan("sqlite3", NOCASE_COL, "SELECT * FROM t WHERE b GLOB 'abc*'"),
70+
plan(g, NOCASE_COL, "SELECT * FROM t WHERE b GLOB 'abc*'"),
71+
);
72+
}
73+
74+
#[test]
75+
fn glob_prefix_rows_match() {
76+
if !sqlite3_available() {
77+
eprintln!("sqlite3 CLI not found; skipping");
78+
return;
79+
}
80+
let g = env!("CARGO_BIN_EXE_graphitesql");
81+
let base = format!(
82+
"{BINARY_COL} INSERT INTO t VALUES\
83+
(1,'abc',1),(2,'abd',2),(3,'abcd',3),(4,'ABC',4),(5,'abz',5),(6,'xabc',6),(7,'ab',7);"
84+
);
85+
for q in [
86+
"SELECT b FROM t WHERE b GLOB 'abc*' ORDER BY b", // case-sensitive: excludes ABC
87+
"SELECT b FROM t WHERE b GLOB 'ab*' ORDER BY b",
88+
"SELECT b FROM t WHERE b GLOB 'abc' ORDER BY b",
89+
"SELECT count(*) FROM t WHERE b GLOB 'a*'",
90+
"SELECT b FROM t WHERE b GLOB 'zzz*' ORDER BY b", // empty range
91+
] {
92+
assert_eq!(rows("sqlite3", &base, q), rows(g, &base, q), "rows for {q}");
93+
}
94+
}

0 commit comments

Comments
 (0)