#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
clippy::string_slice,
clippy::arithmetic_side_effects,
)
)]
use std::fmt;
use std::path::Path;
use diesel::connection::SimpleConnection as _;
use diesel::prelude::*;
use diesel::sql_types::{Integer, Text};
use super::wal;
const BUSY_TIMEOUT_MS: u32 = 1_000;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum SqliteError {
Open {
path: String,
detail: String,
},
Query {
op: &'static str,
detail: String,
},
IntegrityFailed {
detail: String,
},
}
impl fmt::Display for SqliteError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Open { path, detail } => {
write!(f, "could not open the SQLite database at {path}: {detail}")
}
Self::Query { op, detail } => write!(f, "SQLite {op} failed: {detail}"),
Self::IntegrityFailed { detail } => {
write!(f, "SQLite integrity check failed: {detail}")
}
}
}
}
impl std::error::Error for SqliteError {}
#[derive(QueryableByName)]
struct IntegrityRow {
#[diesel(sql_type = Text)]
integrity_check: String,
}
#[derive(QueryableByName)]
struct CheckpointRow {
#[diesel(sql_type = Integer)]
busy: i32,
}
#[derive(QueryableByName)]
struct DataVersionRow {
#[diesel(sql_type = Integer)]
data_version: i32,
}
pub fn open(path: &Path) -> Result<SqliteConnection, SqliteError> {
let Some(target) = super::connection_string(path) else {
return Err(SqliteError::Open {
path: path.display().to_string(),
detail: "the path is not valid UTF-8".to_owned(),
});
};
let mut conn = SqliteConnection::establish(&target).map_err(|e| SqliteError::Open {
path: target.clone(),
detail: e.to_string(),
})?;
conn.batch_execute(&format!(
"PRAGMA busy_timeout = {BUSY_TIMEOUT_MS}; PRAGMA wal_autocheckpoint = 0;"
))
.map_err(|e| SqliteError::Query {
op: "connection pragmas",
detail: e.to_string(),
})?;
Ok(conn)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckpointOutcome {
Truncated,
Busy {
wal_bytes: u64,
},
}
pub fn checkpoint_truncate(
conn: &mut SqliteConnection,
db_path: &Path,
) -> Result<CheckpointOutcome, SqliteError> {
let rows: Vec<CheckpointRow> = diesel::sql_query("PRAGMA wal_checkpoint(TRUNCATE)")
.load(conn)
.map_err(|e| SqliteError::Query {
op: "wal_checkpoint(TRUNCATE)",
detail: e.to_string(),
})?;
let blocked = rows.first().is_none_or(|row| row.busy != 0);
if blocked {
let wal_bytes = std::fs::metadata(wal::wal_path(db_path)).map_or(0, |m| m.len());
return Ok(CheckpointOutcome::Busy { wal_bytes });
}
Ok(CheckpointOutcome::Truncated)
}
pub fn data_version(conn: &mut SqliteConnection) -> Result<i32, SqliteError> {
let rows: Vec<DataVersionRow> = diesel::sql_query("PRAGMA data_version")
.load(conn)
.map_err(|e| SqliteError::Query {
op: "data_version",
detail: e.to_string(),
})?;
rows.first()
.map(|row| row.data_version)
.ok_or_else(|| SqliteError::Query {
op: "data_version",
detail: "the pragma returned no rows".to_owned(),
})
}
pub fn integrity_check(conn: &mut SqliteConnection) -> Result<(), SqliteError> {
let rows: Vec<IntegrityRow> = diesel::sql_query("PRAGMA integrity_check")
.load(conn)
.map_err(|e| SqliteError::Query {
op: "integrity_check",
detail: e.to_string(),
})?;
match rows.first() {
Some(row) if row.integrity_check == "ok" && rows.len() == 1 => Ok(()),
Some(row) => Err(SqliteError::IntegrityFailed {
detail: row.integrity_check.clone(),
}),
None => Err(SqliteError::IntegrityFailed {
detail: "integrity_check returned no rows".to_owned(),
}),
}
}
pub fn ensure_wal_mode(conn: &mut SqliteConnection) -> Result<(), SqliteError> {
conn.batch_execute("PRAGMA journal_mode = WAL;")
.map_err(|e| SqliteError::Query {
op: "journal_mode = WAL",
detail: e.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn seeded(dir: &Path) -> (std::path::PathBuf, SqliteConnection) {
let db = dir.join("app.db");
let mut conn = open(&db).expect("open");
ensure_wal_mode(&mut conn).expect("wal");
conn.batch_execute(
"CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT NOT NULL); \
INSERT INTO t (v) VALUES ('a'), ('b');",
)
.expect("seed");
(db, conn)
}
#[test]
fn checkpoint_truncate_empties_the_wal_and_integrity_passes() {
let dir = tempfile::tempdir().expect("tempdir");
let (db, mut conn) = seeded(dir.path());
assert!(
std::fs::metadata(wal::wal_path(&db))
.expect("wal exists")
.len()
> 0,
"the seed must have left frames in the WAL"
);
assert_eq!(
checkpoint_truncate(&mut conn, &db).expect("checkpoint"),
CheckpointOutcome::Truncated
);
integrity_check(&mut conn).expect("integrity");
}
#[test]
fn a_second_open_connection_does_not_break_the_checkpoint_report() {
let dir = tempfile::tempdir().expect("tempdir");
let (db, mut conn) = seeded(dir.path());
let _reader = open(&db).expect("second connection");
let outcome = checkpoint_truncate(&mut conn, &db).expect("checkpoint");
let wal_bytes = std::fs::metadata(wal::wal_path(&db)).map_or(0, |m| m.len());
match outcome {
CheckpointOutcome::Truncated => assert_eq!(wal_bytes, 0),
CheckpointOutcome::Busy { wal_bytes: n } => assert_eq!(n, wal_bytes),
}
}
#[test]
fn integrity_check_reports_a_corrupted_database() {
let dir = tempfile::tempdir().expect("tempdir");
let db = dir.path().join("broken.db");
{
let (path, mut conn) = seeded(dir.path());
checkpoint_truncate(&mut conn, &path).expect("checkpoint");
drop(conn);
std::fs::copy(&path, &db).expect("copy");
}
let mut bytes = std::fs::read(&db).expect("read");
let len = bytes.len();
for i in (len / 2)..(len / 2 + 256).min(len) {
if let Some(b) = bytes.get_mut(i) {
*b ^= 0xFF;
}
}
std::fs::write(&db, &bytes).expect("write");
let mut conn = open(&db).expect("open");
assert!(
integrity_check(&mut conn).is_err(),
"a corrupted database must fail the integrity check"
);
}
}