koan_core/db/
connection.rs1use std::cell::RefCell;
2use std::collections::HashMap;
3use std::path::Path;
4use std::rc::Rc;
5
6use rusqlite::Connection;
7use thiserror::Error;
8
9use super::schema;
10use crate::config;
11
12#[derive(Debug, Error)]
13pub enum DbError {
14 #[error("sqlite error: {0}")]
15 Sqlite(#[from] rusqlite::Error),
16 #[error("io error: {0}")]
17 Io(#[from] std::io::Error),
18 #[error("refused unsafe bulk delete: {0}")]
21 UnsafeBulkDelete(String),
22}
23
24pub struct Database {
26 pub conn: Connection,
27}
28
29impl Database {
30 pub fn open(path: &Path) -> Result<Self, DbError> {
38 if let Some(parent) = path.parent() {
39 std::fs::create_dir_all(parent)?;
40 }
41
42 let conn = Connection::open(path)?;
43
44 #[cfg(unix)]
45 {
46 use std::os::unix::fs::PermissionsExt;
47 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
48 }
49
50 configure(&conn)?;
51
52 let _ = conn.execute_batch("PRAGMA wal_checkpoint(PASSIVE)");
56
57 schema::create_tables(&conn)?;
58
59 Ok(Self { conn })
60 }
61
62 pub fn open_existing(path: &Path) -> Result<Self, DbError> {
68 let conn = Connection::open(path)?;
69 configure(&conn)?;
70 Ok(Self { conn })
71 }
72
73 pub fn open_default() -> Result<Self, DbError> {
75 Self::open(&config::db_path())
76 }
77}
78
79fn configure(conn: &Connection) -> Result<(), DbError> {
82 conn.pragma_update(None, "journal_mode", "wal")?;
84 conn.pragma_update(None, "foreign_keys", "on")?;
85 conn.pragma_update(None, "busy_timeout", 30000)?;
88 conn.pragma_update(None, "synchronous", "normal")?;
90 register_library_collation(conn)?;
93 Ok(())
94}
95
96pub(crate) fn register_library_collation(conn: &Connection) -> rusqlite::Result<()> {
112 conn.create_collation("LIBRARY", |a, b| {
113 cached_sort_key(a).cmp(&cached_sort_key(b)).then(a.cmp(b))
114 })
115}
116
117thread_local! {
118 static SORT_KEYS: RefCell<HashMap<Box<str>, Rc<[Chunk]>>> = RefCell::new(HashMap::new());
125}
126
127fn cached_sort_key(s: &str) -> Rc<[Chunk]> {
128 SORT_KEYS.with_borrow_mut(|cache| {
129 if let Some(key) = cache.get(s) {
130 return Rc::clone(key);
131 }
132 if cache.len() >= 50_000 {
136 cache.clear();
137 }
138 let key: Rc<[Chunk]> = sort_key(s).into();
139 cache.insert(s.into(), Rc::clone(&key));
140 key
141 })
142}
143
144#[derive(PartialEq, Eq, PartialOrd, Ord)]
147enum Chunk {
148 Number(u128),
149 Text(String),
150}
151
152fn sort_key(s: &str) -> Vec<Chunk> {
153 use unicode_normalization::UnicodeNormalization;
154
155 let folded: String = s
158 .nfd()
159 .filter(|c| !matches!(*c as u32, 0x0300..=0x036F))
160 .flat_map(char::to_lowercase)
161 .collect();
162
163 let mut chunks = Vec::new();
164 let mut rest = folded.as_str();
165 while !rest.is_empty() {
166 let digits = rest
167 .find(|c: char| !c.is_ascii_digit())
168 .unwrap_or(rest.len());
169 if digits > 0 && rest.starts_with(|c: char| c.is_ascii_digit()) {
170 match rest[..digits].parse::<u128>() {
172 Ok(n) => chunks.push(Chunk::Number(n)),
173 Err(_) => chunks.push(Chunk::Text(rest[..digits].to_string())),
174 }
175 rest = &rest[digits..];
176 continue;
177 }
178 let text = rest
179 .find(|c: char| c.is_ascii_digit())
180 .unwrap_or(rest.len())
181 .max(1);
182 chunks.push(Chunk::Text(rest[..text].to_string()));
183 rest = &rest[text..];
184 }
185 chunks
186}
187
188#[cfg(test)]
189mod collation_tests {
190 use super::*;
191
192 fn sorted(names: &[&str]) -> Vec<String> {
193 let conn = Connection::open_in_memory().unwrap();
194 crate::db::schema::create_tables(&conn).unwrap();
195 conn.execute_batch("CREATE TABLE t (name TEXT)").unwrap();
196 for n in names {
197 conn.execute("INSERT INTO t VALUES (?1)", [n]).unwrap();
198 }
199 let mut stmt = conn
200 .prepare("SELECT name FROM t ORDER BY name COLLATE LIBRARY")
201 .unwrap();
202 let rows = stmt.query_map([], |r| r.get::<_, String>(0)).unwrap();
203 rows.map(Result::unwrap).collect()
204 }
205
206 #[test]
207 fn lowercase_does_not_sort_after_everything() {
208 assert_eq!(
209 sorted(&["Zebra", "aphex twin", "Boards of Canada"]),
210 ["aphex twin", "Boards of Canada", "Zebra"]
211 );
212 }
213
214 #[test]
215 fn accents_sort_with_their_base_letter() {
216 assert_eq!(
219 sorted(&["Zomby", "Âme", "Alva Noto"]),
220 ["Alva Noto", "Âme", "Zomby"]
221 );
222 }
223
224 #[test]
225 fn digit_runs_compare_as_numbers() {
226 assert_eq!(
227 sorted(&["Track 10", "Track 2", "Track 1"]),
228 ["Track 1", "Track 2", "Track 10"]
229 );
230 }
231
232 #[test]
233 fn names_differing_only_in_case_keep_a_stable_order() {
234 assert_eq!(
236 sorted(&["kraftwerk", "Kraftwerk"]),
237 ["Kraftwerk", "kraftwerk"]
238 );
239 }
240}