use anyhow::{Context, Result};
use rusqlite::types::ValueRef;
use rusqlite::{params, Connection, OpenFlags, OptionalExtension};
use std::cell::RefCell;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
pub struct SqliteStore {
conn: Box<Connection>,
pub path: PathBuf,
pub tables: Vec<String>,
cache: Box<Cache>,
cancel: Option<(i32, Cancel)>,
}
#[derive(Default)]
struct Cache {
meta: RefCell<HashMap<String, Arc<TableMeta>>>,
shards: RefCell<Vec<Connection>>,
ranges: RefCell<HashMap<String, RowidRanges>>,
}
type RowidRanges = Arc<Vec<(i64, i64)>>;
pub type Cancel = Arc<dyn Fn() -> bool + Send + Sync>;
#[derive(Debug)]
pub struct TableMeta {
pub columns: Vec<String>,
pub has_rowid: bool,
pub primary_key: Vec<String>,
}
fn tune(conn: &Connection, cache_kib: i64) {
let _ = conn.execute_batch(&format!(
"PRAGMA mmap_size = 1099511627776;
PRAGMA cache_size = -{cache_kib};
PRAGMA temp_store = MEMORY;"
));
conn.set_prepared_statement_cache_capacity(64);
}
const CACHE_INTERACTIVE_KIB: i64 = 262_144;
const CACHE_SHARD_KIB: i64 = 8_192;
fn install_cancel(conn: &Connection, check_ops: i32, cancel: &Cancel) {
let cancel = Arc::clone(cancel);
let _ = conn.progress_handler(check_ops, Some(move || cancel()));
}
fn shard_count() -> usize {
std::thread::available_parallelism()
.map(|n| n.get().saturating_sub(2))
.unwrap_or(2)
.clamp(2, 16)
}
const PARALLEL_COUNT_FLOOR: i64 = 200_000;
const RANGES_PER_WORKER: i64 = 64;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Sort {
pub column: String,
pub desc: bool,
}
impl Sort {
fn order_by(&self) -> String {
let dir = self.dir();
format!("\"{}\" {dir}, rowid {dir}", esc(&self.column))
}
fn order_by_reversed(&self) -> String {
let dir = if self.desc { "ASC" } else { "DESC" };
format!("\"{}\" {dir}, rowid {dir}", esc(&self.column))
}
fn dir(&self) -> &'static str {
if self.desc {
"DESC"
} else {
"ASC"
}
}
fn compare_to_marker(&self, table: &str, later: bool) -> String {
self.compare_to_marker_at(table, later, 2)
}
fn compare_to_marker_param(&self, table: &str, later: bool) -> String {
self.compare_to_marker_at(table, later, 1)
}
fn compare_to_marker_at(&self, table: &str, later: bool, param: usize) -> String {
let op = if later != self.desc { ">" } else { "<" };
let col = esc(&self.column);
format!(
"(\"{col}\", rowid) {op} (SELECT \"{col}\", rowid FROM \"{t}\" WHERE rowid = ?{param})",
t = esc(table)
)
}
}
#[derive(Debug, Clone, Copy)]
pub struct PageQuery<'a> {
pub table: &'a str,
pub limit: i64,
pub offset: i64,
pub sort: Option<&'a Sort>,
pub filter: &'a str,
pub hint: Option<&'a PageHint>,
pub known_total: Option<i64>,
}
impl<'a> PageQuery<'a> {
pub fn all(table: &'a str, limit: i64) -> Self {
PageQuery {
table,
limit,
offset: 0,
sort: None,
filter: "",
hint: None,
known_total: None,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct PageHint {
pub offset: i64,
pub first: i64,
pub last: i64,
pub len: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Plan {
Offset,
After(i64),
Before(i64),
Last,
}
impl Plan {
fn pick(
hint: Option<&PageHint>,
offset: i64,
limit: i64,
known_total: Option<i64>,
with_rowid: bool,
) -> Plan {
if !with_rowid {
return Plan::Offset;
}
if known_total.is_some_and(|t| offset > 0 && offset + limit >= t) {
return Plan::Last;
}
match hint {
Some(h) if h.len > 0 && offset == h.offset + h.len => Plan::After(h.last),
Some(h) if offset >= 0 && offset + limit == h.offset => Plan::Before(h.first),
_ => Plan::Offset,
}
}
fn reads_backwards(self) -> bool {
matches!(self, Plan::Before(_) | Plan::Last)
}
fn marker(self) -> Option<i64> {
match self {
Plan::After(r) | Plan::Before(r) => Some(r),
Plan::Offset | Plan::Last => None,
}
}
fn take(self, probe: i64, offset: i64, known_total: Option<i64>) -> i64 {
match (self, known_total) {
(Plan::Last, Some(total)) => (total - offset).clamp(1, probe),
_ => probe,
}
}
fn sql_offset(self, offset: i64) -> i64 {
match self {
Plan::Offset => offset,
_ => 0,
}
}
}
#[derive(Debug, Clone)]
pub struct ColumnStat {
pub name: String,
pub declared: String,
pub rows: i64,
pub nulls: i64,
pub distinct: i64,
pub min: String,
pub max: String,
pub avg: Option<f64>,
pub numeric: i64,
pub longest: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Maintenance {
Vacuum,
Analyze,
Reindex,
}
impl Maintenance {
pub fn label(self) -> &'static str {
match self {
Maintenance::Vacuum => "VACUUM",
Maintenance::Analyze => "ANALYZE",
Maintenance::Reindex => "REINDEX",
}
}
}
#[derive(Debug, Default, PartialEq)]
pub struct Filter {
terms: Vec<Term>,
}
#[derive(Debug, PartialEq)]
enum Term {
Any(String),
Column { column: String, value: String },
}
impl Filter {
pub fn parse(text: &str, columns: &[String]) -> Self {
let mut terms = Vec::new();
for token in text.split_whitespace() {
match token.split_once(':') {
Some((name, value)) if !value.is_empty() => {
match columns.iter().find(|c| c.eq_ignore_ascii_case(name)) {
Some(column) => terms.push(Term::Column {
column: column.clone(),
value: value.to_string(),
}),
None => terms.push(Term::Any(token.to_string())),
}
}
_ => terms.push(Term::Any(token.to_string())),
}
}
Filter { terms }
}
pub fn is_empty(&self) -> bool {
self.terms.is_empty()
}
fn sql(&self, columns: &[String], first_param: usize) -> (String, Vec<String>) {
let mut parts = Vec::new();
let mut binds = Vec::new();
for term in &self.terms {
let n = first_param + binds.len();
match term {
Term::Any(v) => {
if columns.is_empty() {
continue;
}
parts.push(format!("({})", SqliteStore::like_group(columns, n)));
binds.push(format!("%{}%", like_escape(v)));
}
Term::Column { column, value } => {
parts.push(format!(
"(CAST(\"{}\" AS TEXT) LIKE ?{n} ESCAPE '\\')",
esc(column)
));
binds.push(format!("%{}%", like_escape(value)));
}
}
}
(parts.join(" AND "), binds)
}
}
pub struct RowQuery<'a> {
pub table: &'a str,
pub columns: &'a [String],
pub term: &'a str,
pub sort: Option<&'a Sort>,
pub filter: &'a str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RowKey {
Rowid(i64),
Primary(Vec<(String, String)>),
}
pub struct RowsView {
pub columns: Vec<String>,
pub rows: Vec<Vec<String>>,
pub rowids: Vec<Option<i64>>,
pub primary_key: Vec<String>,
pub total: i64,
pub total_exact: bool,
}
impl SqliteStore {
pub fn open(path: &Path) -> Result<Self> {
let conn =
Connection::open(path).with_context(|| format!("open sqlite {}", path.display()))?;
Self::from_conn(conn, path)
}
pub fn open_readonly(path: &Path) -> Result<Self> {
let conn = Connection::open_with_flags(
path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.with_context(|| format!("open sqlite read-only {}", path.display()))?;
Self::from_conn(conn, path)
}
fn from_conn(conn: Connection, path: &Path) -> Result<Self> {
tune(&conn, CACHE_INTERACTIVE_KIB);
let tables = list_tables(&conn)?;
Ok(Self {
conn: Box::new(conn),
path: path.to_path_buf(),
tables,
cache: Box::default(),
cancel: None,
})
}
pub fn set_cancel(&mut self, check_ops: i32, cancel: Cancel) {
install_cancel(&self.conn, check_ops, &cancel);
for conn in self.cache.shards.borrow().iter() {
install_cancel(conn, check_ops, &cancel);
}
self.cancel = Some((check_ops, cancel));
}
pub fn invalidate(&mut self) {
self.cache.meta.borrow_mut().clear();
self.cache.ranges.borrow_mut().clear();
if let Ok(t) = list_tables(&self.conn) {
self.tables = t;
}
}
fn meta(&self, table: &str) -> Result<Arc<TableMeta>> {
if let Some(m) = self.cache.meta.borrow().get(table) {
return Ok(Arc::clone(m));
}
let columns = self.read_columns(table)?;
let has_rowid = self
.conn
.prepare(&format!("SELECT rowid FROM \"{}\" LIMIT 0", esc(table)))
.is_ok();
let primary_key = if has_rowid {
Vec::new()
} else {
self.primary_key_columns(table)?
};
let m = Arc::new(TableMeta {
columns,
has_rowid,
primary_key,
});
self.cache
.meta
.borrow_mut()
.insert(table.to_string(), Arc::clone(&m));
Ok(m)
}
pub fn unreadable_reason(&self, table: &str) -> Option<String> {
match self
.conn
.prepare(&format!("SELECT 1 FROM \"{}\" LIMIT 0", esc(table)))
{
Ok(_) => None,
Err(e) => Some(e.to_string()),
}
}
fn like_group(columns: &[String], param: usize) -> String {
columns
.iter()
.map(|c| format!("CAST(\"{}\" AS TEXT) LIKE ?{param} ESCAPE '\\'", esc(c)))
.collect::<Vec<_>>()
.join(" OR ")
}
fn filter_clause(columns: &[String], filter: &str) -> Option<(String, Vec<String>)> {
let parsed = Filter::parse(filter, columns);
if parsed.is_empty() {
return None;
}
let (body, binds) = parsed.sql(columns, 1);
if body.is_empty() {
return None;
}
Some((format!(" WHERE {body}"), binds))
}
pub fn count_filtered(&self, table: &str, filter: &str) -> Result<i64> {
let columns = self.columns(table)?;
match Self::filter_clause(&columns, filter) {
None => self.count(table),
Some((where_sql, binds)) => {
let sql = format!("SELECT COUNT(*) FROM \"{}\"{}", esc(table), where_sql);
Ok(self
.conn
.query_row(&sql, rusqlite::params_from_iter(binds), |r| r.get(0))?)
}
}
}
pub fn count(&self, table: &str) -> Result<i64> {
let n: i64 = self.conn.query_row(
&format!("SELECT COUNT(*) FROM \"{}\"", esc(table)),
[],
|r| r.get(0),
)?;
Ok(n)
}
pub fn count_exact(&self, table: &str, filter: &str) -> Result<i64> {
let meta = self.meta(table)?;
let (keep, binds) = Self::and_filter(&meta.columns, filter, 1);
let params: Vec<&(dyn rusqlite::ToSql + Sync)> = binds
.iter()
.map(|b| b as &(dyn rusqlite::ToSql + Sync))
.collect();
match self.count_sharded(table, meta.has_rowid, &keep, ¶ms)? {
Some(n) => Ok(n),
None => self.count_filtered(table, filter),
}
}
fn count_sharded(
&self,
table: &str,
has_rowid: bool,
keep: &str,
binds: &[&(dyn rusqlite::ToSql + Sync)],
) -> Result<Option<i64>> {
if !has_rowid {
return Ok(None);
}
let Some(ranges) = self.rowid_ranges(table)? else {
return Ok(None);
};
let lo = binds.len() + 1;
let hi = binds.len() + 2;
let sql = format!(
"SELECT COUNT(*) FROM \"{}\" WHERE rowid > ?{lo} AND rowid <= ?{hi}{keep}",
esc(table)
);
let workers = shard_count().min(ranges.len());
let mut pool = self.cache.shards.borrow_mut();
self.fill_pool(&mut pool, workers)?;
let next = AtomicUsize::new(0);
let totals: Vec<Result<i64>> = std::thread::scope(|scope| {
let handles: Vec<_> = pool
.iter_mut()
.take(workers)
.map(|conn| {
let (sql, ranges, next) = (&sql, &ranges, &next);
scope.spawn(move || -> Result<i64> {
let mut total = 0i64;
loop {
let i = next.fetch_add(1, Ordering::Relaxed);
let Some(&(lo_v, hi_v)) = ranges.get(i) else {
return Ok(total);
};
let mut params: Vec<&dyn rusqlite::ToSql> =
binds.iter().map(|b| *b as &dyn rusqlite::ToSql).collect();
params.push(&lo_v);
params.push(&hi_v);
let mut stmt = conn.prepare_cached(sql)?;
total += stmt.query_row(params.as_slice(), |r| r.get::<_, i64>(0))?;
}
})
})
.collect();
handles
.into_iter()
.map(|h| {
h.join()
.unwrap_or_else(|_| Err(anyhow::anyhow!("count thread panicked")))
})
.collect()
});
let mut total = 0i64;
for t in totals {
total += t?;
}
Ok(Some(total))
}
fn fill_pool(&self, pool: &mut Vec<Connection>, want: usize) -> Result<()> {
while pool.len() < want {
let conn = Connection::open_with_flags(
&self.path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?;
tune(&conn, CACHE_SHARD_KIB);
if let Some((ops, cancel)) = &self.cancel {
install_cancel(&conn, *ops, cancel);
}
pool.push(conn);
}
Ok(())
}
fn rowid_ranges(&self, table: &str) -> Result<Option<RowidRanges>> {
if let Some(r) = self.cache.ranges.borrow().get(table) {
return Ok(Some(Arc::clone(r)));
}
let (min, max): (i64, i64) = self.conn.query_row(
&format!(
"SELECT COALESCE(MIN(rowid), 0), COALESCE(MAX(rowid), 0) FROM \"{}\"",
esc(table)
),
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
let span = max.saturating_sub(min).saturating_add(1);
if span < PARALLEL_COUNT_FLOOR {
return Ok(None);
}
let count = (shard_count() as i64 * RANGES_PER_WORKER).min(span);
let mut ranges = Vec::with_capacity(count as usize);
let mut lo = min - 1;
for k in 1..=count {
let hi = if k == count {
max
} else {
min - 1 + span * k / count
};
if hi > lo {
ranges.push((lo, hi));
lo = hi;
}
}
let ranges = Arc::new(ranges);
self.cache
.ranges
.borrow_mut()
.insert(table.to_string(), Arc::clone(&ranges));
Ok(Some(ranges))
}
pub fn columns(&self, table: &str) -> Result<Vec<String>> {
Ok(self.meta(table)?.columns.clone())
}
fn read_columns(&self, table: &str) -> Result<Vec<String>> {
let mut stmt = self
.conn
.prepare(&format!("PRAGMA table_info(\"{}\")", esc(table)))?;
let cols = stmt
.query_map([], |r| r.get::<_, String>(1))?
.collect::<Result<Vec<_>, _>>()?;
Ok(cols)
}
pub fn rows(&self, q: &PageQuery) -> Result<RowsView> {
let PageQuery {
table,
limit,
offset,
sort,
filter,
hint,
known_total,
} = *q;
let meta = self.meta(table)?;
let columns = &meta.columns;
let ncols = columns.len();
let with_rowid = meta.has_rowid;
let probe = limit.saturating_add(1);
let sorted_by = match sort {
Some(s) if columns.contains(&s.column) => Some(s),
_ => None,
};
let plan = Plan::pick(hint, offset, limit, known_total, with_rowid);
let order = match (with_rowid, sorted_by) {
(true, Some(s)) => s.order_by(),
(true, None) => "rowid".to_string(),
(false, Some(s)) => format!("\"{}\" {}", esc(&s.column), s.dir()),
(false, None) => "1".to_string(),
};
let reversed = plan.reads_backwards();
let order = if reversed {
match (with_rowid, sorted_by) {
(true, Some(s)) => s.order_by_reversed(),
(true, None) => "rowid DESC".to_string(),
(false, Some(s)) => format!(
"\"{}\" {}",
esc(&s.column),
if s.desc { "ASC" } else { "DESC" }
),
(false, None) => "1 DESC".to_string(),
}
} else {
order
};
let marker = plan.marker();
let first_param = if marker.is_some() { 2 } else { 1 };
let (keep, binds) = Self::and_filter(columns, filter, first_param);
let where_sql = match (marker, keep.is_empty()) {
(Some(_), _) => {
let cmp = match sorted_by {
Some(s) => s.compare_to_marker_param(table, !reversed),
None => format!("rowid {} ?1", if reversed { "<" } else { ">" }),
};
format!(" WHERE {cmp}{keep}")
}
(None, false) => format!(" WHERE {}", keep.trim_start_matches(" AND ")),
(None, true) => String::new(),
};
let take = plan.take(probe, offset, known_total);
let select = if with_rowid { "rowid, *" } else { "*" };
let sql = format!(
"SELECT {select} FROM \"{}\"{where_sql} ORDER BY {order} LIMIT {take} OFFSET {}",
esc(table),
plan.sql_offset(offset)
);
let mut stmt = self.conn.prepare_cached(&sql)?;
let mut rows_out = Vec::new();
let mut rowids = Vec::new();
let mut params: Vec<&dyn rusqlite::ToSql> = Vec::new();
if let Some(m) = marker.as_ref() {
params.push(m);
}
params.extend(binds.iter().map(|b| b as &dyn rusqlite::ToSql));
let mut q = stmt.query(params.as_slice())?;
while let Some(row) = q.next()? {
let (base, rid) = if with_rowid {
(1usize, row.get::<_, i64>(0).ok())
} else {
(0usize, None)
};
rowids.push(rid);
let mut cells = Vec::with_capacity(ncols);
for i in 0..ncols {
cells.push(value_to_string(row, base + i));
}
rows_out.push(cells);
}
if reversed {
rows_out.reverse();
rowids.reverse();
}
let more = rows_out.len() as i64 > limit;
if more {
if reversed {
rows_out.remove(0);
rowids.remove(0);
} else {
rows_out.pop();
rowids.pop();
}
}
let (total, total_exact) = match known_total {
Some(n) => (n, true),
None if more => (offset + rows_out.len() as i64 + 1, false),
None => (offset + rows_out.len() as i64, true),
};
Ok(RowsView {
columns: columns.clone(),
rows: rows_out,
rowids,
primary_key: meta.primary_key.clone(),
total,
total_exact,
})
}
pub fn find_row(&self, q: &RowQuery, from_rowid: i64, forward: bool) -> Result<Option<i64>> {
let RowQuery {
table,
columns,
term,
sort,
filter,
} = *q;
if columns.is_empty() {
return Ok(None);
}
let likes = Self::like_group(columns, 1);
let (keep, keep_binds) = Self::and_filter(columns, filter, 3);
let sql = match sort {
Some(s) if columns.contains(&s.column) => format!(
"SELECT rowid FROM \"{}\" WHERE {} AND ({}){} ORDER BY {} LIMIT 1",
esc(table),
s.compare_to_marker(table, forward),
likes,
keep,
if forward {
s.order_by()
} else {
s.order_by_reversed()
}
),
_ => {
let (cmp, ord) = if forward { (">", "ASC") } else { ("<", "DESC") };
format!(
"SELECT rowid FROM \"{}\" WHERE rowid {} ?2 AND ({}){} ORDER BY rowid {} LIMIT 1",
esc(table),
cmp,
likes,
keep,
ord
)
}
};
let pattern = format!("%{}%", like_escape(term));
let mut binds: Vec<&dyn rusqlite::ToSql> = vec![&pattern, &from_rowid];
binds.extend(keep_binds.iter().map(|b| b as &dyn rusqlite::ToSql));
let mut stmt = self.conn.prepare(&sql)?;
let rid = stmt
.query_row(binds.as_slice(), |r| r.get::<_, i64>(0))
.optional()?;
Ok(rid)
}
fn and_filter(columns: &[String], filter: &str, first_param: usize) -> (String, Vec<String>) {
let parsed = Filter::parse(filter, columns);
let (body, binds) = parsed.sql(columns, first_param);
if body.is_empty() {
(String::new(), Vec::new())
} else {
(format!(" AND {body}"), binds)
}
}
pub fn find_row_edge(&self, q: &RowQuery, forward: bool) -> Result<Option<i64>> {
let RowQuery {
table,
columns,
term,
sort,
filter,
} = *q;
if columns.is_empty() {
return Ok(None);
}
let likes = Self::like_group(columns, 1);
let (keep, keep_binds) = Self::and_filter(columns, filter, 2);
let order = match sort {
Some(s) if columns.contains(&s.column) => {
if forward {
s.order_by()
} else {
s.order_by_reversed()
}
}
_ => format!("rowid {}", if forward { "ASC" } else { "DESC" }),
};
let sql = format!(
"SELECT rowid FROM \"{}\" WHERE ({}){} ORDER BY {} LIMIT 1",
esc(table),
likes,
keep,
order
);
let pattern = format!("%{}%", like_escape(term));
let mut binds: Vec<&dyn rusqlite::ToSql> = vec![&pattern];
binds.extend(keep_binds.iter().map(|b| b as &dyn rusqlite::ToSql));
let mut stmt = self.conn.prepare(&sql)?;
Ok(stmt
.query_row(binds.as_slice(), |r| r.get::<_, i64>(0))
.optional()?)
}
pub fn rowid_ordinal(
&self,
table: &str,
rowid: i64,
sort: Option<&Sort>,
filter: &str,
) -> Result<i64> {
let meta = self.meta(table)?;
let (filter_body, filter_binds) = Self::and_filter(&meta.columns, filter, 2);
let position = match sort {
Some(s) => format!("NOT ({})", s.compare_to_marker_param(table, true)),
None => "rowid <= ?1".to_string(),
};
let mut binds: Vec<&(dyn rusqlite::ToSql + Sync)> = vec![&rowid];
binds.extend(
filter_binds
.iter()
.map(|b| b as &(dyn rusqlite::ToSql + Sync)),
);
let keep = format!(" AND {position}{filter_body}");
if let Some(n) = self.count_sharded(table, meta.has_rowid, &keep, &binds)? {
return Ok(n);
}
let sql = format!(
"SELECT COUNT(*) FROM \"{}\" WHERE {position}{filter_body}",
esc(table)
);
let params: Vec<&dyn rusqlite::ToSql> =
binds.iter().map(|b| *b as &dyn rusqlite::ToSql).collect();
let n: i64 = self.conn.query_row(&sql, params.as_slice(), |r| r.get(0))?;
Ok(n)
}
pub fn schema(&self) -> Result<Vec<(String, String, String)>> {
let mut stmt = self.conn.prepare(
"SELECT type, name, COALESCE(sql, '') FROM sqlite_master \
WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name",
)?;
let out = stmt
.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
))
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(out)
}
fn where_key(key: &RowKey) -> (String, Vec<String>) {
match key {
RowKey::Rowid(id) => ("rowid = ?1".to_string(), vec![id.to_string()]),
RowKey::Primary(pairs) => {
let clause = pairs
.iter()
.enumerate()
.map(|(i, (c, _))| format!("CAST(\"{}\" AS TEXT) = ?{}", esc(c), i + 1))
.collect::<Vec<_>>()
.join(" AND ");
(clause, pairs.iter().map(|(_, v)| v.clone()).collect())
}
}
}
pub fn update_cell_keyed(
&self,
table: &str,
key: &RowKey,
col: &str,
val: &str,
) -> Result<usize> {
let (clause, mut binds) = Self::where_key(key);
let sql = format!(
"UPDATE \"{}\" SET \"{}\" = ?{} WHERE {}",
esc(table),
esc(col),
binds.len() + 1,
clause
);
binds.push(val.to_string());
Ok(self.conn.execute(&sql, rusqlite::params_from_iter(binds))?)
}
pub fn update_cell_blob_keyed(
&self,
table: &str,
key: &RowKey,
col: &str,
bytes: &[u8],
) -> Result<usize> {
let (clause, binds) = Self::where_key(key);
let sql = format!(
"UPDATE \"{}\" SET \"{}\" = ?{} WHERE {}",
esc(table),
esc(col),
binds.len() + 1,
clause
);
let mut params: Vec<&dyn rusqlite::ToSql> =
binds.iter().map(|b| b as &dyn rusqlite::ToSql).collect();
params.push(&bytes);
Ok(self.conn.execute(&sql, params.as_slice())?)
}
pub fn delete_row_keyed(&self, table: &str, key: &RowKey) -> Result<usize> {
let (clause, binds) = Self::where_key(key);
let sql = format!("DELETE FROM \"{}\" WHERE {}", esc(table), clause);
Ok(self.conn.execute(&sql, rusqlite::params_from_iter(binds))?)
}
pub fn cell_bytes_keyed(&self, table: &str, key: &RowKey, col: &str) -> Result<Vec<u8>> {
let (clause, binds) = Self::where_key(key);
let sql = format!(
"SELECT \"{}\" FROM \"{}\" WHERE {} LIMIT 1",
esc(col),
esc(table),
clause
);
Ok(self
.conn
.query_row(&sql, rusqlite::params_from_iter(binds), |r| {
Ok(match r.get_ref(0)? {
ValueRef::Blob(b) => b.to_vec(),
ValueRef::Text(t) => t.to_vec(),
ValueRef::Integer(i) => i.to_string().into_bytes(),
ValueRef::Real(f) => f.to_string().into_bytes(),
ValueRef::Null => Vec::new(),
})
})?)
}
pub fn cell_is_blob_keyed(&self, table: &str, key: &RowKey, col: &str) -> Result<bool> {
let (clause, binds) = Self::where_key(key);
let sql = format!(
"SELECT typeof(\"{}\") FROM \"{}\" WHERE {} LIMIT 1",
esc(col),
esc(table),
clause
);
let t: String = self
.conn
.query_row(&sql, rusqlite::params_from_iter(binds), |r| r.get(0))?;
Ok(t == "blob")
}
pub fn insert_blank(&self, table: &str) -> Result<()> {
self.conn.execute(
&format!("INSERT INTO \"{}\" DEFAULT VALUES", esc(table)),
[],
)?;
Ok(())
}
pub fn exec(&self, sql: &str) -> Result<usize> {
Ok(self.conn.execute(sql, [])?)
}
pub fn run(&self, sql: &str, limit: usize) -> Result<Outcome> {
let mut stmt = self.conn.prepare(sql)?;
if stmt.column_count() == 0 {
drop(stmt);
return Ok(Outcome::Changed(self.exec(sql)?));
}
let columns: Vec<String> = stmt
.column_names()
.into_iter()
.map(|c| c.to_string())
.collect();
let ncols = columns.len();
let mut rows = Vec::new();
let mut literals = Vec::new();
let mut truncated = false;
let mut q = stmt.query([])?;
while let Some(row) = q.next()? {
if rows.len() >= limit {
truncated = true;
break;
}
rows.push((0..ncols).map(|i| value_to_string(row, i)).collect());
literals.push((0..ncols).map(|i| value_to_literal(row, i)).collect());
}
Ok(Outcome::Rows {
columns,
rows,
literals,
truncated,
})
}
pub fn db_info(&self) -> Vec<(String, String)> {
let scalar = |sql: &str| -> String {
self.conn
.query_row(sql, [], |r| r.get::<_, rusqlite::types::Value>(0))
.map(|v| match v {
rusqlite::types::Value::Integer(i) => i.to_string(),
rusqlite::types::Value::Text(t) => t,
rusqlite::types::Value::Real(f) => f.to_string(),
rusqlite::types::Value::Null => "—".into(),
rusqlite::types::Value::Blob(b) => format!("<{} bytes>", b.len()),
})
.unwrap_or_else(|e| format!("? ({e})"))
};
let mut out = vec![
("page size".into(), scalar("PRAGMA page_size")),
("page count".into(), scalar("PRAGMA page_count")),
("freelist pages".into(), scalar("PRAGMA freelist_count")),
("encoding".into(), scalar("PRAGMA encoding")),
("journal mode".into(), scalar("PRAGMA journal_mode")),
("synchronous".into(), scalar("PRAGMA synchronous")),
("auto vacuum".into(), scalar("PRAGMA auto_vacuum")),
("schema version".into(), scalar("PRAGMA schema_version")),
("user version".into(), scalar("PRAGMA user_version")),
("application id".into(), scalar("PRAGMA application_id")),
("foreign keys".into(), scalar("PRAGMA foreign_keys")),
];
if let (Ok(ps), Ok(pc)) = (
scalar("PRAGMA page_size").parse::<u64>(),
scalar("PRAGMA page_count").parse::<u64>(),
) {
out.push(("data size".into(), format!("{} bytes", ps * pc)));
}
let counts = |what: &str, sql: &str| -> (String, String) {
(
what.into(),
self.conn
.query_row(sql, [], |r| r.get::<_, i64>(0))
.map(|n| n.to_string())
.unwrap_or_else(|_| "?".into()),
)
};
out.push(counts(
"tables",
"SELECT count(*) FROM sqlite_master WHERE type='table'",
));
out.push(counts(
"indexes",
"SELECT count(*) FROM sqlite_master WHERE type='index'",
));
out.push(counts(
"views",
"SELECT count(*) FROM sqlite_master WHERE type='view'",
));
out.push(counts(
"triggers",
"SELECT count(*) FROM sqlite_master WHERE type='trigger'",
));
out
}
pub fn integrity_check(&self, quick: bool) -> Result<Vec<String>> {
let sql = if quick {
"PRAGMA quick_check"
} else {
"PRAGMA integrity_check"
};
let mut stmt = self.conn.prepare(sql)?;
let rows = stmt
.query_map([], |r| r.get::<_, String>(0))?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(rows)
}
pub fn missing_fk_indexes(&self) -> Result<Vec<String>> {
let mut out = Vec::new();
for table in &self.tables {
let mut fks = self
.conn
.prepare(&format!("PRAGMA foreign_key_list(\"{}\")", esc(table)))?;
let keys: Vec<(String, String)> = fks
.query_map([], |r| Ok((r.get::<_, String>(2)?, r.get::<_, String>(3)?)))?
.filter_map(|r| r.ok())
.collect();
if keys.is_empty() {
continue;
}
let mut idx = self
.conn
.prepare(&format!("PRAGMA index_list(\"{}\")", esc(table)))?;
let indexes: Vec<String> = idx
.query_map([], |r| r.get::<_, String>(1))?
.filter_map(|r| r.ok())
.collect();
let mut first_cols: Vec<String> = Vec::new();
for i in &indexes {
let mut info = self
.conn
.prepare(&format!("PRAGMA index_info(\"{}\")", esc(i)))?;
let cols: Vec<Option<String>> = info
.query_map([], |r| r.get::<_, Option<String>>(2))?
.filter_map(|r| r.ok())
.collect();
if let Some(Some(c)) = cols.into_iter().next() {
first_cols.push(c);
}
}
for (parent, child_col) in keys {
if !first_cols
.iter()
.any(|c| c.eq_ignore_ascii_case(&child_col))
{
out.push(format!(
"{table}.{child_col} -> {parent}: no index on the child column"
));
}
}
}
Ok(out)
}
pub fn explain_plan(&self, sql: &str) -> Result<Vec<String>> {
let mut stmt = self.conn.prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?;
let steps: Vec<(i64, i64, String)> = stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(3)?)))?
.filter_map(|r| r.ok())
.collect();
Ok(plan_tree(&steps))
}
pub fn dump(&self, table: Option<&str>) -> Result<String> {
let filter = match table {
Some(t) => format!(" AND tbl_name = '{}'", t.replace('\'', "''")),
None => String::new(),
};
let sql = format!(
"SELECT type, name, sql FROM sqlite_master \
WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'{filter} \
ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 ELSE 2 END, name"
);
let mut stmt = self.conn.prepare(&sql)?;
let objects: Vec<(String, String, String)> = stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
.filter_map(|r| r.ok())
.collect();
let virtuals: Vec<&String> = objects
.iter()
.filter(|(_, _, sql)| {
sql.trim_start()
.to_uppercase()
.starts_with("CREATE VIRTUAL")
})
.map(|(_, name, _)| name)
.collect();
let is_shadow = |name: &str| {
virtuals
.iter()
.any(|v| name.len() > v.len() + 1 && name.starts_with(&format!("{v}_")))
};
let mut out = String::from("PRAGMA foreign_keys=OFF;\nBEGIN TRANSACTION;\n");
if !virtuals.is_empty() {
out.push_str("PRAGMA writable_schema=ON;\n");
}
for (kind, name, create) in &objects {
if kind != "table" {
out.push_str(create.trim_end_matches(';'));
out.push_str(";\n");
continue;
}
if virtuals.contains(&name) {
out.push_str(&format!(
"INSERT INTO sqlite_schema(type,name,tbl_name,rootpage,sql)VALUES('table','{}','{}',0,'{}');\n",
name.replace('\'', "''"),
name.replace('\'', "''"),
create.trim_end_matches(';').replace('\'', "''")
));
continue;
}
if is_shadow(name) {
let create = create.trim_end_matches(';');
let created = create.replacen("CREATE TABLE ", "CREATE TABLE IF NOT EXISTS ", 1);
out.push_str(&created);
out.push_str(";\n");
} else {
out.push_str(create.trim_end_matches(';'));
out.push_str(";\n");
}
for row in self.literal_rows(name)? {
out.push_str(&format!(
"INSERT INTO {} VALUES({});\n",
quoted_name(name),
row.join(",")
));
}
}
if !virtuals.is_empty() {
out.push_str("PRAGMA writable_schema=OFF;\n");
}
out.push_str("COMMIT;\n");
Ok(out)
}
pub fn literal_rows(&self, table: &str) -> Result<Vec<Vec<String>>> {
let mut stmt = self
.conn
.prepare(&format!("SELECT * FROM {}", quoted_name(table)))?;
let ncols = stmt.column_count();
let mut out = Vec::new();
let mut q = stmt.query([])?;
while let Some(row) = q.next()? {
let mut cells = Vec::with_capacity(ncols);
for i in 0..ncols {
cells.push(value_to_literal(row, i));
}
out.push(cells);
}
Ok(out)
}
pub fn backup_to(&self, path: &std::path::Path) -> Result<()> {
let target = path.to_string_lossy().replace('\'', "''");
self.conn
.execute_batch(&format!("VACUUM INTO '{target}'"))?;
Ok(())
}
pub fn attach(&self, path: &Path, alias: &str) -> Result<()> {
self.conn.execute(
&format!("ATTACH DATABASE ?1 AS \"{}\"", esc(alias)),
params![path.to_string_lossy()],
)?;
Ok(())
}
pub fn detach(&self, alias: &str) -> Result<()> {
self.conn
.execute_batch(&format!("DETACH DATABASE \"{}\"", esc(alias)))?;
Ok(())
}
pub fn databases(&self) -> Result<Vec<(String, String)>> {
let mut stmt = self.conn.prepare("PRAGMA database_list")?;
let out = stmt
.query_map([], |r| {
Ok((r.get::<_, String>(1)?, r.get::<_, Option<String>>(2)?))
})?
.filter_map(|r| r.ok())
.map(|(alias, file)| (alias, file.unwrap_or_else(|| "(temporary)".into())))
.collect();
Ok(out)
}
pub fn indexes(&self, table: &str) -> Result<Vec<(String, Vec<String>)>> {
let mut stmt = self
.conn
.prepare(&format!("PRAGMA index_list(\"{}\")", esc(table)))?;
let names: Vec<String> = stmt
.query_map([], |r| r.get::<_, String>(1))?
.filter_map(|r| r.ok())
.collect();
let mut out = Vec::with_capacity(names.len());
for name in names {
let mut info = self
.conn
.prepare(&format!("PRAGMA index_info(\"{}\")", esc(&name)))?;
let cols: Vec<String> = info
.query_map([], |r| r.get::<_, Option<String>>(2))?
.filter_map(|r| r.ok())
.flatten()
.collect();
out.push((name, cols));
}
Ok(out)
}
pub fn index_advice(&self, sql: &str) -> Result<Vec<String>> {
let plan = self.explain_plan(sql)?;
let scanned: Vec<String> = plan
.iter()
.filter_map(|line| {
let rest = line.trim_start_matches(['|', '`', '-', ' ']);
let name = rest.strip_prefix("SCAN ")?.split_whitespace().next()?;
self.tables.iter().find(|t| t.as_str() == name).cloned()
})
.collect();
if scanned.is_empty() {
return Ok(Vec::new());
}
let mentioned = compared_columns(sql);
let mut out = Vec::new();
for table in scanned {
let columns = self.columns(&table)?;
let mut useful: Vec<String> = Vec::new();
for name in &mentioned {
if !columns.iter().any(|c| c.eq_ignore_ascii_case(name)) {
continue;
}
let ambiguous = self
.tables
.iter()
.filter(|t| *t != &table)
.filter_map(|t| self.columns(t).ok())
.any(|cols| cols.iter().any(|c| c.eq_ignore_ascii_case(name)));
if !ambiguous && !useful.iter().any(|c| c.eq_ignore_ascii_case(name)) {
useful.push(name.clone());
}
}
let already: Vec<Vec<String>> = self
.indexes(&table)?
.into_iter()
.map(|(_, cols)| cols)
.collect();
useful.retain(|c| {
!already
.iter()
.any(|cols| cols.first().is_some_and(|f| f.eq_ignore_ascii_case(c)))
});
if useful.is_empty() {
out.push(format!(
"{table}: full scan, and no unindexed column of it is compared here"
));
} else {
out.push(format!(
"{table}: full scan — CREATE INDEX \"{table}_{}\" ON \"{table}\"({});",
useful.join("_"),
useful
.iter()
.map(|c| format!("\"{}\"", esc(c)))
.collect::<Vec<_>>()
.join(", ")
));
}
}
Ok(out)
}
pub fn foreign_keys(&self, table: &str) -> Result<Vec<(String, String, String)>> {
let mut stmt = self
.conn
.prepare(&format!("PRAGMA foreign_key_list(\"{}\")", esc(table)))?;
let raw: Vec<(String, String, Option<String>)> = stmt
.query_map([], |r| Ok((r.get(3)?, r.get(2)?, r.get(4)?)))?
.filter_map(|r| r.ok())
.collect();
let mut out = Vec::with_capacity(raw.len());
for (child, parent, parent_col) in raw {
let col = match parent_col {
Some(c) => c,
None => self.primary_key(&parent)?.unwrap_or_else(|| "rowid".into()),
};
out.push((child, parent, col));
}
Ok(out)
}
pub fn primary_key_columns(&self, table: &str) -> Result<Vec<String>> {
let mut stmt = self
.conn
.prepare(&format!("PRAGMA table_info(\"{}\")", esc(table)))?;
let mut keyed: Vec<(i64, String)> = stmt
.query_map([], |r| Ok((r.get::<_, i64>(5)?, r.get::<_, String>(1)?)))?
.filter_map(|r| r.ok())
.filter(|(pk, _)| *pk > 0)
.collect();
keyed.sort_by_key(|(pk, _)| *pk);
Ok(keyed.into_iter().map(|(_, name)| name).collect())
}
fn primary_key(&self, table: &str) -> Result<Option<String>> {
let mut stmt = self
.conn
.prepare(&format!("PRAGMA table_info(\"{}\")", esc(table)))?;
let keys: Vec<String> = stmt
.query_map([], |r| Ok((r.get::<_, String>(1)?, r.get::<_, i64>(5)?)))?
.filter_map(|r| r.ok())
.filter(|(_, pk)| *pk > 0)
.map(|(name, _)| name)
.collect();
Ok(if keys.len() == 1 {
keys.into_iter().next()
} else {
None
})
}
pub fn rowid_where(&self, table: &str, column: &str, value: &str) -> Result<Option<i64>> {
let sql = format!(
"SELECT rowid FROM \"{}\" WHERE CAST(\"{}\" AS TEXT) = ?1 LIMIT 1",
esc(table),
esc(column)
);
Ok(self
.conn
.query_row(&sql, params![value], |r| r.get::<_, i64>(0))
.optional()?)
}
pub fn column_stats(&self, table: &str) -> Result<Vec<ColumnStat>> {
let columns = self.columns(table)?;
let mut out = Vec::with_capacity(columns.len());
let types: std::collections::HashMap<String, String> = self
.conn
.prepare(&format!("PRAGMA table_info(\"{}\")", esc(table)))?
.query_map([], |r| Ok((r.get::<_, String>(1)?, r.get::<_, String>(2)?)))?
.filter_map(|r| r.ok())
.collect();
for c in &columns {
let sql = format!(
"SELECT count(*), count(\"{c}\"), count(DISTINCT \"{c}\"), \
min(\"{c}\"), max(\"{c}\"), \
avg(CASE WHEN typeof(\"{c}\") IN ('integer','real') THEN \"{c}\" END), \
sum(typeof(\"{c}\") IN ('integer','real')), \
max(length(\"{c}\")) \
FROM \"{t}\"",
c = esc(c),
t = esc(table)
);
let mut stmt = self.conn.prepare(&sql)?;
let mut q = stmt.query([])?;
let row = match q.next()? {
Some(r) => r,
None => continue,
};
let rows: i64 = row.get(0)?;
let non_null: i64 = row.get(1)?;
out.push(ColumnStat {
name: c.clone(),
declared: types.get(c).cloned().unwrap_or_default(),
rows,
nulls: rows - non_null,
distinct: row.get(2)?,
min: value_to_string(row, 3),
max: value_to_string(row, 4),
avg: row.get::<_, Option<f64>>(5)?,
numeric: row.get::<_, Option<i64>>(6)?.unwrap_or(0),
longest: row.get::<_, Option<i64>>(7)?.unwrap_or(0),
});
}
Ok(out)
}
pub fn frequency(&self, table: &str, column: &str, limit: i64) -> Result<Vec<(String, i64)>> {
let sql = format!(
"SELECT \"{c}\", count(*) AS n FROM \"{t}\" \
GROUP BY \"{c}\" ORDER BY n DESC, \"{c}\" LIMIT ?1",
c = esc(column),
t = esc(table)
);
let mut stmt = self.conn.prepare(&sql)?;
let mut q = stmt.query([limit])?;
let mut out = Vec::new();
while let Some(row) = q.next()? {
out.push((value_to_string(row, 0), row.get(1)?));
}
Ok(out)
}
pub fn maintain(&self, op: Maintenance) -> Result<i64> {
let before = std::fs::metadata(&self.path).map(|m| m.len()).unwrap_or(0) as i64;
self.conn.execute_batch(match op {
Maintenance::Vacuum => "VACUUM",
Maintenance::Analyze => "ANALYZE",
Maintenance::Reindex => "REINDEX",
})?;
let after = std::fs::metadata(&self.path).map(|m| m.len()).unwrap_or(0) as i64;
Ok(after - before)
}
pub fn import_rows(
&self,
table: &str,
header: &[String],
rows: &[Vec<String>],
) -> Result<usize> {
let columns = self.columns(table)?;
for h in header {
if !columns.iter().any(|c| c == h) {
return Err(anyhow::anyhow!("{table} has no column {h:?}"));
}
}
let cols = header
.iter()
.map(|c| format!("\"{}\"", esc(c)))
.collect::<Vec<_>>()
.join(", ");
let marks = (1..=header.len())
.map(|i| format!("?{i}"))
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
"INSERT INTO \"{}\" ({}) VALUES ({})",
esc(table),
cols,
marks
);
self.conn.execute_batch("BEGIN")?;
let mut done = 0usize;
let result = (|| -> Result<()> {
let mut stmt = self.conn.prepare(&sql)?;
for row in rows {
if row.len() != header.len() {
return Err(anyhow::anyhow!(
"row {} has {} fields, the header has {}",
done + 1,
row.len(),
header.len()
));
}
stmt.execute(rusqlite::params_from_iter(row.iter()))?;
done += 1;
}
Ok(())
})();
match result {
Ok(()) => {
self.conn.execute_batch("COMMIT")?;
Ok(done)
}
Err(e) => {
self.conn.execute_batch("ROLLBACK")?;
Err(e)
}
}
}
pub fn schema_names(&self) -> Vec<(String, Vec<String>)> {
self.tables
.iter()
.map(|t| (t.clone(), self.columns(t).unwrap_or_default()))
.collect()
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum Outcome {
Rows {
columns: Vec<String>,
rows: Vec<Vec<String>>,
literals: Vec<Vec<String>>,
truncated: bool,
},
Changed(usize),
}
fn plan_tree(steps: &[(i64, i64, String)]) -> Vec<String> {
if steps.is_empty() {
return Vec::new();
}
let mut out = vec!["QUERY PLAN".to_string()];
fn walk(steps: &[(i64, i64, String)], parent: i64, prefix: &str, out: &mut Vec<String>) {
let kids: Vec<&(i64, i64, String)> =
steps.iter().filter(|(_, p, _)| *p == parent).collect();
for (i, (id, _, detail)) in kids.iter().enumerate() {
let last = i + 1 == kids.len();
out.push(format!(
"{prefix}{}{detail}",
if last { "`--" } else { "|--" }
));
walk(
steps,
*id,
&format!("{prefix}{}", if last { " " } else { "| " }),
out,
);
}
}
walk(steps, 0, "", &mut out);
out
}
fn quoted_name(name: &str) -> String {
if !name.is_empty()
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& !name.chars().next().unwrap().is_ascii_digit()
{
name.to_string()
} else {
format!("\"{}\"", name.replace('"', "\"\""))
}
}
fn value_to_literal(row: &rusqlite::Row, idx: usize) -> String {
match row.get_ref(idx) {
Ok(ValueRef::Null) => "NULL".into(),
Ok(ValueRef::Integer(i)) => i.to_string(),
Ok(ValueRef::Real(f)) => {
let s = format!("{f:?}");
if s.contains(['.', 'e', 'E', 'n']) {
s
} else {
format!("{s}.0")
}
}
Ok(ValueRef::Text(t)) => format!("'{}'", String::from_utf8_lossy(t).replace('\'', "''")),
Ok(ValueRef::Blob(b)) => {
let mut out = String::with_capacity(b.len() * 2 + 3);
out.push_str("x'");
for byte in b {
out.push_str(&format!("{byte:02x}"));
}
out.push('\'');
out
}
Err(_) => "NULL".into(),
}
}
fn value_to_string(row: &rusqlite::Row, idx: usize) -> String {
match row.get_ref(idx) {
Ok(ValueRef::Null) => "NULL".into(),
Ok(ValueRef::Integer(i)) => i.to_string(),
Ok(ValueRef::Real(f)) => f.to_string(),
Ok(ValueRef::Text(t)) => String::from_utf8_lossy(t).into_owned(),
Ok(ValueRef::Blob(b)) => format!("<blob {} bytes>", b.len()),
Err(_) => "?".into(),
}
}
fn list_tables(conn: &Connection) -> Result<Vec<String>> {
let mut stmt = conn.prepare(
"SELECT name FROM sqlite_master \
WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%' \
ORDER BY name",
)?;
let names = stmt
.query_map([], |r| r.get::<_, String>(0))?
.collect::<Result<Vec<_>, _>>()?;
Ok(names)
}
fn compared_columns(sql: &str) -> Vec<String> {
let lowered = sql.to_lowercase();
let words: Vec<&str> = lowered
.split(|c: char| !(c.is_alphanumeric() || c == '_' || c == '.'))
.filter(|w| !w.is_empty())
.collect();
const AFTER: &[&str] = &["where", "and", "or", "on", "by", "having"];
let mut out = Vec::new();
for (i, w) in words.iter().enumerate() {
if !AFTER.contains(w) {
continue;
}
if let Some(next) = words.get(i + 1) {
let name = next.rsplit('.').next().unwrap_or(next);
if name.chars().next().is_some_and(|c| c.is_alphabetic())
&& !AFTER.contains(&name)
&& !out.iter().any(|o: &String| o == name)
{
out.push(name.to_string());
}
}
}
out
}
fn esc(ident: &str) -> String {
ident.replace('"', "\"\"")
}
fn like_escape(term: &str) -> String {
let mut out = String::with_capacity(term.len());
for c in term.chars() {
if matches!(c, '\\' | '%' | '_') {
out.push('\\');
}
out.push(c);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture(name: &str, rows: i64) -> (PathBuf, SqliteStore) {
let mut path = std::env::temp_dir();
path.push(format!("zdbview_sqlite_{}_{name}.db", std::process::id()));
let _ = std::fs::remove_file(&path);
let conn = Connection::open(&path).unwrap();
conn.execute_batch("CREATE TABLE t (name TEXT, n INTEGER)")
.unwrap();
{
let tx = conn.unchecked_transaction().unwrap();
let mut stmt = tx
.prepare("INSERT INTO t (name, n) VALUES (?1, ?2)")
.unwrap();
for i in 0..rows {
let word = if i % 5 == 0 {
format!("kick-{i}")
} else {
format!("word-{i}")
};
stmt.execute(params![word, i]).unwrap();
}
drop(stmt);
tx.commit().unwrap();
}
drop(conn);
let store = SqliteStore::open(&path).unwrap();
(path, store)
}
fn page<'a>(
table: &'a str,
limit: i64,
offset: i64,
filter: &'a str,
hint: Option<&'a PageHint>,
known_total: Option<i64>,
) -> PageQuery<'a> {
PageQuery {
table,
limit,
offset,
sort: None,
filter,
hint,
known_total,
}
}
fn hint_for(view: &RowsView, offset: i64) -> PageHint {
PageHint {
offset,
first: view.rowids.first().copied().flatten().unwrap(),
last: view.rowids.last().copied().flatten().unwrap(),
len: view.rows.len() as i64,
}
}
#[test]
fn cursor_paging_matches_offset_paging_forwards_and_back() {
let (path, store) = fixture("cursor", 250);
let first = store.rows(&page("t", 50, 0, "", None, None)).unwrap();
let mut hint = hint_for(&first, 0);
for step in 1..5 {
let offset = step * 50;
let by_cursor = store
.rows(&page("t", 50, offset, "", Some(&hint), None))
.unwrap();
let by_offset = store.rows(&page("t", 50, offset, "", None, None)).unwrap();
assert_eq!(
by_cursor.rowids, by_offset.rowids,
"page at offset {offset} differs between cursor and offset paging"
);
assert_eq!(by_cursor.rows, by_offset.rows);
hint = hint_for(&by_cursor, offset);
}
for step in (0..4).rev() {
let offset = step * 50;
let by_cursor = store
.rows(&page("t", 50, offset, "", Some(&hint), None))
.unwrap();
let by_offset = store.rows(&page("t", 50, offset, "", None, None)).unwrap();
assert_eq!(
by_cursor.rowids, by_offset.rowids,
"backwards page at offset {offset} differs"
);
hint = hint_for(&by_cursor, offset);
}
let _ = std::fs::remove_file(path);
}
#[test]
fn cursor_paging_matches_offset_paging_under_a_filter() {
let (path, store) = fixture("cursor_filtered", 500);
let first = store.rows(&page("t", 20, 0, "kick", None, None)).unwrap();
assert_eq!(first.rows.len(), 20, "the filter must leave a full page");
let hint = hint_for(&first, 0);
let by_cursor = store
.rows(&page("t", 20, 20, "kick", Some(&hint), None))
.unwrap();
let by_offset = store.rows(&page("t", 20, 20, "kick", None, None)).unwrap();
assert_eq!(by_cursor.rowids, by_offset.rowids);
let _ = std::fs::remove_file(path);
}
#[test]
fn the_last_page_read_backwards_matches_the_offset_route() {
let (path, store) = fixture("last", 250);
let total = store.count_exact("t", "").unwrap();
assert_eq!(total, 250);
let offset = ((total - 1) / 60) * 60;
let backwards = store
.rows(&page("t", 60, offset, "", None, Some(total)))
.unwrap();
let forwards = store.rows(&page("t", 60, offset, "", None, None)).unwrap();
assert_eq!(backwards.rowids, forwards.rowids, "last page differs");
assert_eq!(backwards.rows.len(), 10, "250 rows in 60s leaves 10");
assert!(backwards.total_exact);
let _ = std::fs::remove_file(path);
}
#[test]
fn a_page_reports_an_exact_total_only_when_it_has_seen_the_end() {
let (path, store) = fixture("totals", 30);
let mid = store.rows(&page("t", 10, 0, "", None, None)).unwrap();
assert!(!mid.total_exact, "rows follow, so the total is a bound");
assert_eq!(
mid.total, 11,
"the bound is what the extra probe row proves"
);
let end = store.rows(&page("t", 10, 20, "", None, None)).unwrap();
assert!(end.total_exact, "the page ended the table");
assert_eq!(end.total, 30);
let known = store.rows(&page("t", 10, 0, "", None, Some(30))).unwrap();
assert_eq!((known.total, known.total_exact), (30, true));
assert_eq!(known.rows.len(), 10, "the probe row is never displayed");
let _ = std::fs::remove_file(path);
}
#[test]
fn the_parallel_count_agrees_with_one_statement() {
let (path, store) = fixture("count", 2_000);
store
.conn
.execute("UPDATE t SET rowid = rowid + 5000000 WHERE n = 1999", [])
.unwrap();
store.cache.ranges.borrow_mut().clear();
assert!(
store.rowid_ranges("t").unwrap().is_some(),
"a span this wide must be split"
);
for filter in ["", "kick", "name:word", "kick nothing"] {
assert_eq!(
store.count_exact("t", filter).unwrap(),
store.count_filtered("t", filter).unwrap(),
"parallel and single-statement counts disagree for {filter:?}"
);
}
let _ = std::fs::remove_file(path);
}
#[test]
fn rowid_ranges_cover_every_row_exactly_once() {
let (path, store) = fixture("ranges", 400);
store
.conn
.execute("UPDATE t SET rowid = rowid * 100000", [])
.unwrap();
store.cache.ranges.borrow_mut().clear();
let ranges = store.rowid_ranges("t").unwrap().expect("wide span splits");
let mut seen = 0i64;
for &(lo, hi) in ranges.iter() {
assert!(lo < hi, "range ({lo}, {hi}] is empty or inverted");
seen += store
.conn
.query_row(
"SELECT COUNT(*) FROM t WHERE rowid > ?1 AND rowid <= ?2",
params![lo, hi],
|r| r.get::<_, i64>(0),
)
.unwrap();
}
assert_eq!(seen, 400, "ranges must partition the table");
for pair in ranges.windows(2) {
assert_eq!(
pair[0].1, pair[1].0,
"ranges must not overlap or leave gaps"
);
}
let _ = std::fs::remove_file(path);
}
#[test]
fn a_narrow_table_is_not_split() {
let (path, store) = fixture("small", 100);
assert!(store.rowid_ranges("t").unwrap().is_none());
assert_eq!(store.count_exact("t", "kick").unwrap(), 20);
let _ = std::fs::remove_file(path);
}
#[test]
fn a_table_needing_a_missing_module_is_reported_unreadable() {
let (path, store) = fixture("module", 10);
drop(store);
let conn = Connection::open(&path).unwrap();
conn.execute_batch(
"PRAGMA writable_schema = ON;
INSERT INTO sqlite_master (type, name, tbl_name, rootpage, sql)
VALUES ('table', 'weird', 'weird', 0,
'CREATE VIRTUAL TABLE weird USING no_such_module(a)');
PRAGMA writable_schema = OFF;",
)
.unwrap();
drop(conn);
let store = SqliteStore::open(&path).unwrap();
assert!(
store.tables.iter().any(|t| t == "weird"),
"the table is listed: {:?}",
store.tables
);
let why = store
.unreadable_reason("weird")
.expect("a missing module must be reported");
assert!(why.contains("no such module"), "unexpected reason: {why}");
assert!(
store.unreadable_reason("t").is_none(),
"an ordinary table is readable"
);
let _ = std::fs::remove_file(path);
}
#[test]
fn the_search_ordinal_is_the_rows_position_in_the_display_order() {
let (path, store) = fixture("ordinal", 2_000);
store
.conn
.execute("UPDATE t SET rowid = rowid + 5000000 WHERE n >= 1000", [])
.unwrap();
store.cache.ranges.borrow_mut().clear();
let view = store.rows(&page("t", 5, 0, "kick", None, None)).unwrap();
let third = view.rowids[2].unwrap();
assert_eq!(
store.rowid_ordinal("t", third, None, "kick").unwrap(),
3,
"the third listed match is at position 3"
);
let sort = Sort {
column: "n".into(),
desc: true,
};
let desc = store
.rows(&PageQuery {
sort: Some(&sort),
..page("t", 5, 0, "kick", None, None)
})
.unwrap();
assert_eq!(
store
.rowid_ordinal("t", desc.rowids[0].unwrap(), Some(&sort), "kick")
.unwrap(),
1,
"the first row of a descending sort is at position 1"
);
let _ = std::fs::remove_file(path);
}
#[test]
fn invalidate_forgets_a_stale_column_list() {
let (path, mut store) = fixture("schema", 10);
assert_eq!(store.columns("t").unwrap(), vec!["name", "n"]);
store
.conn
.execute("ALTER TABLE t ADD COLUMN extra TEXT", [])
.unwrap();
assert_eq!(
store.columns("t").unwrap(),
vec!["name", "n"],
"the cache is what makes a page cheap, so it holds until told otherwise"
);
store.invalidate();
assert_eq!(store.columns("t").unwrap(), vec!["name", "n", "extra"]);
let _ = std::fs::remove_file(path);
}
}