use std::cell::RefCell;
use std::collections::HashMap;
use std::path::Path;
use std::rc::Rc;
use rusqlite::Connection;
use thiserror::Error;
use super::schema;
use crate::config;
#[derive(Debug, Error)]
pub enum DbError {
#[error("sqlite error: {0}")]
Sqlite(#[from] rusqlite::Error),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("refused unsafe bulk delete: {0}")]
UnsafeBulkDelete(String),
}
pub struct Database {
pub conn: Connection,
}
impl Database {
pub fn open(path: &Path) -> Result<Self, DbError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let conn = Connection::open(path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
configure(&conn)?;
let _ = conn.execute_batch("PRAGMA wal_checkpoint(PASSIVE)");
schema::create_tables(&conn)?;
Ok(Self { conn })
}
pub fn open_existing(path: &Path) -> Result<Self, DbError> {
let conn = Connection::open(path)?;
configure(&conn)?;
Ok(Self { conn })
}
pub fn open_default() -> Result<Self, DbError> {
Self::open(&config::db_path())
}
}
fn configure(conn: &Connection) -> Result<(), DbError> {
conn.pragma_update(None, "journal_mode", "wal")?;
conn.pragma_update(None, "foreign_keys", "on")?;
conn.pragma_update(None, "busy_timeout", 30000)?;
conn.pragma_update(None, "synchronous", "normal")?;
register_library_collation(conn)?;
Ok(())
}
pub(crate) fn register_library_collation(conn: &Connection) -> rusqlite::Result<()> {
conn.create_collation("LIBRARY", |a, b| {
cached_sort_key(a).cmp(&cached_sort_key(b)).then(a.cmp(b))
})
}
thread_local! {
static SORT_KEYS: RefCell<HashMap<Box<str>, Rc<[Chunk]>>> = RefCell::new(HashMap::new());
}
fn cached_sort_key(s: &str) -> Rc<[Chunk]> {
SORT_KEYS.with_borrow_mut(|cache| {
if let Some(key) = cache.get(s) {
return Rc::clone(key);
}
if cache.len() >= 50_000 {
cache.clear();
}
let key: Rc<[Chunk]> = sort_key(s).into();
cache.insert(s.into(), Rc::clone(&key));
key
})
}
#[derive(PartialEq, Eq, PartialOrd, Ord)]
enum Chunk {
Number(u128),
Text(String),
}
fn sort_key(s: &str) -> Vec<Chunk> {
use unicode_normalization::UnicodeNormalization;
let folded: String = s
.nfd()
.filter(|c| !matches!(*c as u32, 0x0300..=0x036F))
.flat_map(char::to_lowercase)
.collect();
let mut chunks = Vec::new();
let mut rest = folded.as_str();
while !rest.is_empty() {
let digits = rest
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(rest.len());
if digits > 0 && rest.starts_with(|c: char| c.is_ascii_digit()) {
match rest[..digits].parse::<u128>() {
Ok(n) => chunks.push(Chunk::Number(n)),
Err(_) => chunks.push(Chunk::Text(rest[..digits].to_string())),
}
rest = &rest[digits..];
continue;
}
let text = rest
.find(|c: char| c.is_ascii_digit())
.unwrap_or(rest.len())
.max(1);
chunks.push(Chunk::Text(rest[..text].to_string()));
rest = &rest[text..];
}
chunks
}
#[cfg(test)]
mod collation_tests {
use super::*;
fn sorted(names: &[&str]) -> Vec<String> {
let conn = Connection::open_in_memory().unwrap();
crate::db::schema::create_tables(&conn).unwrap();
conn.execute_batch("CREATE TABLE t (name TEXT)").unwrap();
for n in names {
conn.execute("INSERT INTO t VALUES (?1)", [n]).unwrap();
}
let mut stmt = conn
.prepare("SELECT name FROM t ORDER BY name COLLATE LIBRARY")
.unwrap();
let rows = stmt.query_map([], |r| r.get::<_, String>(0)).unwrap();
rows.map(Result::unwrap).collect()
}
#[test]
fn lowercase_does_not_sort_after_everything() {
assert_eq!(
sorted(&["Zebra", "aphex twin", "Boards of Canada"]),
["aphex twin", "Boards of Canada", "Zebra"]
);
}
#[test]
fn accents_sort_with_their_base_letter() {
assert_eq!(
sorted(&["Zomby", "Âme", "Alva Noto"]),
["Alva Noto", "Âme", "Zomby"]
);
}
#[test]
fn digit_runs_compare_as_numbers() {
assert_eq!(
sorted(&["Track 10", "Track 2", "Track 1"]),
["Track 1", "Track 2", "Track 10"]
);
}
#[test]
fn names_differing_only_in_case_keep_a_stable_order() {
assert_eq!(
sorted(&["kraftwerk", "Kraftwerk"]),
["Kraftwerk", "kraftwerk"]
);
}
}