Rindle docs and package mapSkip to main content

rindle_sqlite/
cost_model.rs

1//! `SqliteCostModel` — a planner [`ConnectionCostModel`] backed by real SQLite
2//! statistics (port of `zqlite/src/sqlite-cost-model.ts`).
3//!
4//! For each table scan the planner asks about, we build a single-table `SELECT`
5//! (constraint columns as `= ?` placeholders, filters with their literals inlined so
6//! SQLite's planner sees real values, plus the `ORDER BY`), prepare it, and read the
7//! query planner's per-loop estimates via `scanstatus`:
8//!
9//! - **rows** = the main scan loop's estimated row count.
10//! - **startup_cost** = `btree_cost(rows)` for each top-level `ORDER BY` sort loop.
11//! - **fanout** = from `SQLiteStatFanout` (`stat4`/`stat1`).
12//!
13//! This is a faithful *logic* port, not a byte-for-byte JS differential: the row /
14//! fanout numbers come from this build's SQLite + `ANALYZE` stats, which differ from
15//! the JS engine's. It is validated by real-SQLite integration tests.
16
17use std::ffi::{CStr, CString};
18use std::os::raw::{c_char, c_int, c_void};
19use std::rc::Rc;
20
21use rindle::{Condition, Dir, Lit, Op, OrderPart, SimpleCondition, ValuePosition};
22use rindle_planner::{ConnectionCostModel, CostModelCost, PlannerConstraint};
23use rusqlite::{ffi, Connection};
24
25use crate::query_builder::ident;
26use crate::stat_fanout::SQLiteStatFanout;
27
28/// SQLite's default fanout when statistics are unavailable (`SQLiteStatFanout`'s
29/// default).
30pub const DEFAULT_FANOUT: f64 = 3.0;
31
32/// A cost model that estimates table-scan cost from a live SQLite connection.
33///
34/// Holds a shared [`Connection`] (via `Rc`, since the planner takes an owned
35/// `Rc<dyn ConnectionCostModel>`); statements are prepared on demand against it.
36pub struct SqliteCostModel {
37    conn: Rc<Connection>,
38    fanout: Rc<SQLiteStatFanout>,
39}
40
41impl SqliteCostModel {
42    pub fn new(conn: Rc<Connection>) -> Self {
43        let fanout = Rc::new(SQLiteStatFanout::new(conn.clone(), DEFAULT_FANOUT));
44        Self { conn, fanout }
45    }
46}
47
48impl ConnectionCostModel for SqliteCostModel {
49    fn estimate(
50        &self,
51        table: &str,
52        sort: &[OrderPart],
53        filters: Option<&Condition>,
54        constraint: Option<&PlannerConstraint>,
55    ) -> CostModelCost {
56        // The cost model can't estimate correlated subqueries, so strip them
57        // (conservative — the real cost may be higher).
58        let no_sub_filters = filters.and_then(remove_correlated_subqueries);
59
60        let sql = build_cost_sql(table, sort, no_sub_filters.as_ref(), constraint);
61        let loops = unsafe { read_scanstatus_loops(&self.conn, &sql) };
62        assert!(
63            !loops.is_empty(),
64            "scanstatus returned no loops for cost query: {sql}"
65        );
66        let (rows, startup_cost) = estimate_cost(&loops);
67
68        let fanout = self.fanout.clone();
69        let table: Box<str> = Box::from(table);
70        CostModelCost {
71            rows,
72            startup_cost,
73            fanout: Rc::new(move |columns| fanout.get_fanout(&table, columns)),
74        }
75    }
76}
77
78// ---------------------------------------------------------------------------
79// scanstatus reading (raw FFI)
80// ---------------------------------------------------------------------------
81
82/// One `scanstatus` loop: the planner's per-loop estimate + EXPLAIN text.
83pub(crate) struct ScanLoop {
84    pub select_id: i32,
85    pub parent_id: i32,
86    pub est: f64,
87    pub explain: String,
88}
89
90/// Raw-prepare `sql`, read all `scanstatus` loops (`SQLITE_SCANSTAT_COMPLEX` so sort
91/// loops are included), then finalize. `est`/`explain` are planner estimates available
92/// after prepare (no execution needed). Panics if `sql` fails to prepare (a cost-model
93/// invariant); a caller that must not panic uses [`try_read_scanstatus_loops`].
94pub(crate) unsafe fn read_scanstatus_loops(conn: &Connection, sql: &str) -> Vec<ScanLoop> {
95    try_read_scanstatus_loops(conn, sql)
96        .unwrap_or_else(|| panic!("cost-model prepare failed for: {sql}"))
97}
98
99/// [`read_scanstatus_loops`] that returns `None` when `sql` won't prepare (interior NUL or
100/// a SQLite prepare error) instead of panicking — for the `analyze` diagnostic, which must
101/// never bring down the daemon on a malformed leaf `SELECT`.
102pub(crate) unsafe fn try_read_scanstatus_loops(
103    conn: &Connection,
104    sql: &str,
105) -> Option<Vec<ScanLoop>> {
106    let db = conn.handle();
107    let csql = CString::new(sql).ok()?;
108    let mut stmt: *mut ffi::sqlite3_stmt = std::ptr::null_mut();
109    let rc = ffi::sqlite3_prepare_v2(db, csql.as_ptr(), -1, &mut stmt, std::ptr::null_mut());
110    if rc != ffi::SQLITE_OK {
111        return None;
112    }
113
114    let flags = ffi::SQLITE_SCANSTAT_COMPLEX as c_int;
115    let mut out = Vec::new();
116    let mut idx: c_int = 0;
117    loop {
118        let mut select_id: c_int = 0;
119        let rc = ffi::sqlite3_stmt_scanstatus_v2(
120            stmt,
121            idx,
122            ffi::SQLITE_SCANSTAT_SELECTID as c_int,
123            flags,
124            &mut select_id as *mut c_int as *mut c_void,
125        );
126        if rc != 0 {
127            break; // idx past the last loop
128        }
129        let mut parent_id: c_int = 0;
130        ffi::sqlite3_stmt_scanstatus_v2(
131            stmt,
132            idx,
133            ffi::SQLITE_SCANSTAT_PARENTID as c_int,
134            flags,
135            &mut parent_id as *mut c_int as *mut c_void,
136        );
137        let mut est: f64 = 0.0;
138        ffi::sqlite3_stmt_scanstatus_v2(
139            stmt,
140            idx,
141            ffi::SQLITE_SCANSTAT_EST as c_int,
142            flags,
143            &mut est as *mut f64 as *mut c_void,
144        );
145        let mut explain_ptr: *const c_char = std::ptr::null();
146        ffi::sqlite3_stmt_scanstatus_v2(
147            stmt,
148            idx,
149            ffi::SQLITE_SCANSTAT_EXPLAIN as c_int,
150            flags,
151            &mut explain_ptr as *mut *const c_char as *mut c_void,
152        );
153        let explain = if explain_ptr.is_null() {
154            String::new()
155        } else {
156            CStr::from_ptr(explain_ptr).to_string_lossy().into_owned()
157        };
158
159        out.push(ScanLoop {
160            select_id,
161            parent_id,
162            est,
163            explain,
164        });
165        idx += 1;
166    }
167    ffi::sqlite3_finalize(stmt);
168    Some(out)
169}
170
171/// The SQLite access-path text for `sql` — the planner's per-loop `EXPLAIN` lines
172/// (`"SCAN comments"`, `"SEARCH comments USING INDEX …"`, `"USE TEMP B-TREE FOR ORDER BY"`),
173/// joined with `"; "` when a leaf has several. Read off a fresh prepare (no execution), so
174/// it is exactly the plan SQLite chose for that `SELECT`. `None` if `sql` won't prepare or
175/// the plan exposes no loop text. `analyze query` shows this beside each leaf's
176/// scanned/emitted ratio: a `SCAN` on an amplified leaf is the smoking gun the ratio flags.
177#[cfg(feature = "scan-stats")]
178pub fn explain_plan(conn: &Connection, sql: &str) -> Option<String> {
179    let loops = unsafe { try_read_scanstatus_loops(conn, sql)? };
180    let text: Vec<String> = loops
181        .into_iter()
182        .map(|l| l.explain)
183        .filter(|e| !e.trim().is_empty())
184        .collect();
185    (!text.is_empty()).then(|| text.join("; "))
186}
187
188// ---------------------------------------------------------------------------
189// cost estimation (`estimateCost` / `btreeCost`)
190// ---------------------------------------------------------------------------
191
192/// `estimateCost`: rows = the first top-level (`parentId == 0`) loop's estimate;
193/// startup cost accrues `btree_cost(rows)` for each later top-level `ORDER BY` loop.
194fn estimate_cost(loops: &[ScanLoop]) -> (f64, f64) {
195    let mut top: Vec<&ScanLoop> = loops.iter().filter(|l| l.parent_id == 0).collect();
196    top.sort_by_key(|l| l.select_id);
197
198    let mut total_rows = 0.0;
199    let mut total_cost = 0.0;
200    let mut first = true;
201    for op in top {
202        if first {
203            total_rows = op.est;
204            first = false;
205        } else if op.explain.contains("ORDER BY") {
206            total_cost += btree_cost(total_rows);
207        }
208    }
209    (total_rows, total_cost)
210}
211
212/// `btreeCost` (`sqlite-cost-model.ts`): `(rows * log2(rows)) / 10` — O(n log n) sort,
213/// `/10` because SQLite sorts ~10× faster than pulling rows into the host. Operation
214/// order preserved (multiply then divide).
215pub fn btree_cost(rows: f64) -> f64 {
216    (rows * rows.log2()) / 10.0
217}
218
219// ---------------------------------------------------------------------------
220// cost-query SQL (single-table SELECT with inlined filter literals)
221// ---------------------------------------------------------------------------
222
223/// `removeCorrelatedSubqueries`: drop EXISTS / NOT EXISTS from the filter tree (the
224/// cost model can't price them); collapse empty / singleton AND/OR.
225fn remove_correlated_subqueries(condition: &Condition) -> Option<Condition> {
226    match condition {
227        Condition::CorrelatedSubquery(_) => None,
228        Condition::Simple(_) => Some(condition.clone()),
229        Condition::And { conditions } => {
230            let kept: Vec<Condition> = conditions
231                .iter()
232                .filter_map(remove_correlated_subqueries)
233                .collect();
234            collapse(kept, true)
235        }
236        Condition::Or { conditions } => {
237            let kept: Vec<Condition> = conditions
238                .iter()
239                .filter_map(remove_correlated_subqueries)
240                .collect();
241            collapse(kept, false)
242        }
243    }
244}
245
246fn collapse(mut kept: Vec<Condition>, is_and: bool) -> Option<Condition> {
247    match kept.len() {
248        0 => None,
249        1 => kept.pop(),
250        _ => Some(if is_and {
251            Condition::And { conditions: kept }
252        } else {
253            Condition::Or { conditions: kept }
254        }),
255    }
256}
257
258/// Build the single-table cost query: `SELECT * FROM table [WHERE …] [ORDER BY …]`.
259/// `SELECT *` is faithful for the planner (a full-row fetch, like the JS column list);
260/// constraint columns are `= ?` (value-less placeholders, so SQLite uses the index via
261/// the average), and `filters` inline their literals.
262fn build_cost_sql(
263    table: &str,
264    sort: &[OrderPart],
265    filters: Option<&Condition>,
266    constraint: Option<&PlannerConstraint>,
267) -> String {
268    let mut sql = format!("SELECT * FROM {}", ident(table));
269
270    let mut wheres: Vec<String> = Vec::new();
271    if let Some(c) = constraint {
272        for col in c {
273            wheres.push(format!("{} = ?", ident(col)));
274        }
275    }
276    if let Some(f) = filters {
277        wheres.push(condition_to_sql(f));
278    }
279    if !wheres.is_empty() {
280        sql.push_str(" WHERE ");
281        sql.push_str(&wheres.join(" AND "));
282    }
283
284    if !sort.is_empty() {
285        sql.push_str(" ORDER BY ");
286        let parts: Vec<String> = sort
287            .iter()
288            .map(|p| {
289                let dir = match p.dir() {
290                    Dir::Asc => "ASC",
291                    Dir::Desc => "DESC",
292                };
293                format!("{} {}", ident(p.field()), dir)
294            })
295            .collect();
296        sql.push_str(&parts.join(", "));
297    }
298
299    sql
300}
301
302fn condition_to_sql(condition: &Condition) -> String {
303    match condition {
304        Condition::Simple(s) => simple_to_sql(s),
305        Condition::And { conditions } => group(conditions, "AND"),
306        Condition::Or { conditions } => group(conditions, "OR"),
307        // Removed by remove_correlated_subqueries before we get here.
308        Condition::CorrelatedSubquery(_) => unreachable!("subqueries stripped for cost SQL"),
309    }
310}
311
312fn group(conditions: &[Condition], op: &str) -> String {
313    let inner: Vec<String> = conditions.iter().map(condition_to_sql).collect();
314    format!("({})", inner.join(&format!(" {op} ")))
315}
316
317fn simple_to_sql(s: &SimpleCondition) -> String {
318    let left = value_position_to_sql(&s.left);
319    match s.op {
320        Op::In | Op::NotIn => {
321            let op = if s.op == Op::In { "IN" } else { "NOT IN" };
322            let elems = match &s.right {
323                ValuePosition::Literal {
324                    value: Lit::Array(items),
325                } => items.iter().map(inline_lit).collect::<Vec<_>>().join(", "),
326                other => value_position_to_sql(other),
327            };
328            format!("{left} {op} ({elems})")
329        }
330        _ => format!(
331            "{left} {} {}",
332            op_to_sql(s.op),
333            value_position_to_sql(&s.right)
334        ),
335    }
336}
337
338fn value_position_to_sql(v: &ValuePosition) -> String {
339    match v {
340        ValuePosition::Column { name } => ident(name),
341        ValuePosition::Literal { value } => inline_lit(value),
342    }
343}
344
345fn op_to_sql(op: Op) -> &'static str {
346    match op {
347        Op::Eq => "=",
348        Op::Ne => "!=",
349        Op::Lt => "<",
350        Op::Le => "<=",
351        Op::Gt => ">",
352        Op::Ge => ">=",
353        Op::Is => "IS",
354        Op::IsNot => "IS NOT",
355        // For cost estimation, the case-insensitive ILIKE collapses to LIKE; the exact
356        // collation does not change the scan plan SQLite picks.
357        Op::Like | Op::ILike => "LIKE",
358        Op::NotLike | Op::NotILike => "NOT LIKE",
359        Op::In => "IN",
360        Op::NotIn => "NOT IN",
361    }
362}
363
364/// Inline a literal into the cost SQL (`compileInline` / `inlineValue`).
365fn inline_lit(lit: &Lit) -> String {
366    match lit {
367        Lit::Null => "NULL".to_string(),
368        Lit::Bool(b) => if *b { "1" } else { "0" }.to_string(),
369        Lit::Int(i) => i.to_string(),
370        Lit::Number(n) => format!("{n}"),
371        Lit::Str(s) => format!("'{}'", s.replace('\'', "''")),
372        Lit::Array(items) => {
373            // A bare array literal (arrays normally appear in IN, handled above). Emit a
374            // JSON-text form so the SQL stays valid.
375            let inner: Vec<String> = items.iter().map(json_lit).collect();
376            format!("'[{}]'", inner.join(","))
377        }
378    }
379}
380
381fn json_lit(lit: &Lit) -> String {
382    match lit {
383        Lit::Null => "null".to_string(),
384        Lit::Bool(b) => b.to_string(),
385        Lit::Int(i) => i.to_string(),
386        Lit::Number(n) => format!("{n}"),
387        Lit::Str(s) => format!("{s:?}"),
388        Lit::Array(items) => {
389            let inner: Vec<String> = items.iter().map(json_lit).collect();
390            format!("[{}]", inner.join(","))
391        }
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    fn cset(items: &[&str]) -> PlannerConstraint {
400        items.iter().map(|s| Box::from(*s)).collect()
401    }
402
403    #[test]
404    fn build_cost_sql_shapes() {
405        // No filters/constraint/sort → bare scan.
406        assert_eq!(
407            build_cost_sql("users", &[], None, None),
408            r#"SELECT * FROM "users""#
409        );
410
411        // Constraint column → `= ?` placeholder.
412        assert_eq!(
413            build_cost_sql("posts", &[], None, Some(&cset(&["userId"]))),
414            r#"SELECT * FROM "posts" WHERE "userId" = ?"#
415        );
416
417        // Sort → ORDER BY.
418        let sort = vec![OrderPart(Box::from("created"), Dir::Desc)];
419        assert_eq!(
420            build_cost_sql("posts", &sort, None, Some(&cset(&["userId"]))),
421            r#"SELECT * FROM "posts" WHERE "userId" = ? ORDER BY "created" DESC"#
422        );
423    }
424
425    #[test]
426    fn filter_literals_inline() {
427        let filter = Condition::Simple(SimpleCondition {
428            op: Op::Eq,
429            left: ValuePosition::Column {
430                name: Box::from("active"),
431            },
432            right: ValuePosition::Literal {
433                value: Lit::Bool(true),
434            },
435        });
436        assert_eq!(
437            build_cost_sql("users", &[], Some(&filter), None),
438            r#"SELECT * FROM "users" WHERE "active" = 1"#
439        );
440    }
441
442    #[test]
443    fn remove_correlated_subqueries_collapses() {
444        // A bare CSQ → None.
445        let csq = Condition::CorrelatedSubquery(rindle::CorrelatedSubqueryCondition {
446            related: rindle::CorrelatedSubquery {
447                correlation: rindle::Correlation {
448                    parent_field: vec![Box::from("id")],
449                    child_field: vec![Box::from("userId")],
450                },
451                subquery: Box::new(rindle::Ast {
452                    table: Box::from("posts"),
453                    ..Default::default()
454                }),
455                system: None,
456            },
457            op: rindle::ExistsOp::Exists,
458            flip: None,
459            scalar: None,
460            plan_id: None,
461        });
462        assert!(remove_correlated_subqueries(&csq).is_none());
463
464        // AND[simple, CSQ] → just the simple.
465        let simple = Condition::Simple(SimpleCondition {
466            op: Op::Gt,
467            left: ValuePosition::Column {
468                name: Box::from("n"),
469            },
470            right: ValuePosition::Literal {
471                value: Lit::Number(3.0),
472            },
473        });
474        let and = Condition::And {
475            conditions: vec![simple.clone(), csq],
476        };
477        assert_eq!(remove_correlated_subqueries(&and), Some(simple));
478    }
479
480    #[test]
481    fn btree_cost_formula() {
482        // (rows * log2(rows)) / 10
483        assert_eq!(btree_cost(1024.0), (1024.0 * 10.0) / 10.0); // log2(1024)=10
484        assert_eq!(btree_cost(1.0), 0.0); // log2(1)=0
485    }
486}