| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
use rusqlite::Connection; |
| 10 |
|
| 11 |
use crate::spec::ConfigSpec; |
| 12 |
|
| 13 |
|
| 14 |
|
| 15 |
|
| 16 |
|
| 17 |
|
| 18 |
#[derive(Debug, thiserror::Error)] |
| 19 |
pub enum ConfigError { |
| 20 |
#[error("config store: {0}")] |
| 21 |
Db(#[from] rusqlite::Error), |
| 22 |
} |
| 23 |
|
| 24 |
pub type Result<T> = std::result::Result<T, ConfigError>; |
| 25 |
|
| 26 |
|
| 27 |
|
| 28 |
|
| 29 |
|
| 30 |
|
| 31 |
|
| 32 |
pub struct ConfigStore { |
| 33 |
table: &'static str, |
| 34 |
} |
| 35 |
|
| 36 |
impl ConfigStore { |
| 37 |
|
| 38 |
|
| 39 |
|
| 40 |
|
| 41 |
|
| 42 |
pub fn open(conn: &Connection, spec: &ConfigSpec) -> Result<Self> { |
| 43 |
let table = spec.table(); |
| 44 |
|
| 45 |
|
| 46 |
|
| 47 |
conn.execute_batch(&format!( |
| 48 |
"CREATE TABLE IF NOT EXISTS {table} (\ |
| 49 |
key TEXT PRIMARY KEY,\ |
| 50 |
value TEXT NOT NULL\ |
| 51 |
) WITHOUT ROWID;" |
| 52 |
))?; |
| 53 |
Ok(Self { table }) |
| 54 |
} |
| 55 |
|
| 56 |
|
| 57 |
|
| 58 |
|
| 59 |
|
| 60 |
|
| 61 |
|
| 62 |
|
| 63 |
|
| 64 |
|
| 65 |
|
| 66 |
|
| 67 |
#[must_use] |
| 68 |
pub const fn attached(spec: &ConfigSpec) -> Self { |
| 69 |
Self { |
| 70 |
table: spec.table(), |
| 71 |
} |
| 72 |
} |
| 73 |
|
| 74 |
|
| 75 |
|
| 76 |
|
| 77 |
|
| 78 |
|
| 79 |
pub fn get(&self, conn: &Connection, key: &str) -> Result<Option<String>> { |
| 80 |
let value = conn |
| 81 |
.query_row( |
| 82 |
&format!("SELECT value FROM {} WHERE key = ?1", self.table), |
| 83 |
[key], |
| 84 |
|row| row.get::<_, String>(0), |
| 85 |
) |
| 86 |
.map(Some) |
| 87 |
.or_else(|error| match error { |
| 88 |
rusqlite::Error::QueryReturnedNoRows => Ok(None), |
| 89 |
other => Err(other), |
| 90 |
})?; |
| 91 |
Ok(value) |
| 92 |
} |
| 93 |
|
| 94 |
|
| 95 |
|
| 96 |
|
| 97 |
|
| 98 |
|
| 99 |
pub fn set(&self, conn: &Connection, key: &str, value: &str) -> Result<()> { |
| 100 |
conn.execute( |
| 101 |
&format!( |
| 102 |
"INSERT INTO {} (key, value) VALUES (?1, ?2) \ |
| 103 |
ON CONFLICT(key) DO UPDATE SET value = excluded.value", |
| 104 |
self.table |
| 105 |
), |
| 106 |
(key, value), |
| 107 |
)?; |
| 108 |
Ok(()) |
| 109 |
} |
| 110 |
|
| 111 |
|
| 112 |
pub fn unset(&self, conn: &Connection, key: &str) -> Result<()> { |
| 113 |
conn.execute(&format!("DELETE FROM {} WHERE key = ?1", self.table), [key])?; |
| 114 |
Ok(()) |
| 115 |
} |
| 116 |
|
| 117 |
|
| 118 |
|
| 119 |
|
| 120 |
|
| 121 |
pub fn all(&self, conn: &Connection) -> Result<Vec<(String, String)>> { |
| 122 |
let mut stmt = conn.prepare(&format!( |
| 123 |
"SELECT key, value FROM {} ORDER BY key", |
| 124 |
self.table |
| 125 |
))?; |
| 126 |
let rows = stmt |
| 127 |
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? |
| 128 |
.collect::<rusqlite::Result<Vec<_>>>()?; |
| 129 |
Ok(rows) |
| 130 |
} |
| 131 |
} |
| 132 |
|
| 133 |
#[cfg(test)] |
| 134 |
mod tests { |
| 135 |
use super::*; |
| 136 |
use crate::spec::Posture; |
| 137 |
|
| 138 |
const SPEC: ConfigSpec = ConfigSpec::new( |
| 139 |
"app_config", |
| 140 |
&[("theme", Posture::Synced), ("mirror_path", Posture::Local)], |
| 141 |
); |
| 142 |
|
| 143 |
fn store() -> (Connection, ConfigStore) { |
| 144 |
let conn = Connection::open_in_memory().unwrap(); |
| 145 |
let store = ConfigStore::open(&conn, &SPEC).unwrap(); |
| 146 |
(conn, store) |
| 147 |
} |
| 148 |
|
| 149 |
#[test] |
| 150 |
fn a_value_round_trips() { |
| 151 |
let (conn, store) = store(); |
| 152 |
assert_eq!(store.get(&conn, "theme").unwrap(), None, "unset reads None"); |
| 153 |
|
| 154 |
store.set(&conn, "theme", "akari-night").unwrap(); |
| 155 |
assert_eq!( |
| 156 |
store.get(&conn, "theme").unwrap().as_deref(), |
| 157 |
Some("akari-night"), |
| 158 |
); |
| 159 |
} |
| 160 |
|
| 161 |
#[test] |
| 162 |
fn set_replaces_rather_than_duplicating() { |
| 163 |
let (conn, store) = store(); |
| 164 |
store.set(&conn, "theme", "flatwhite").unwrap(); |
| 165 |
store.set(&conn, "theme", "akari-night").unwrap(); |
| 166 |
assert_eq!( |
| 167 |
store.get(&conn, "theme").unwrap().as_deref(), |
| 168 |
Some("akari-night"), |
| 169 |
); |
| 170 |
assert_eq!(store.all(&conn).unwrap().len(), 1, "one row, not two"); |
| 171 |
} |
| 172 |
|
| 173 |
#[test] |
| 174 |
fn unset_removes_and_is_idempotent() { |
| 175 |
let (conn, store) = store(); |
| 176 |
store.set(&conn, "theme", "flatwhite").unwrap(); |
| 177 |
store.unset(&conn, "theme").unwrap(); |
| 178 |
assert_eq!(store.get(&conn, "theme").unwrap(), None); |
| 179 |
|
| 180 |
store.unset(&conn, "theme").unwrap(); |
| 181 |
} |
| 182 |
|
| 183 |
#[test] |
| 184 |
fn open_is_idempotent() { |
| 185 |
let (conn, _store) = store(); |
| 186 |
|
| 187 |
|
| 188 |
ConfigStore::open(&conn, &SPEC).unwrap(); |
| 189 |
} |
| 190 |
|
| 191 |
|
| 192 |
|
| 193 |
|
| 194 |
#[test] |
| 195 |
fn attached_uses_an_existing_table() { |
| 196 |
let conn = Connection::open_in_memory().unwrap(); |
| 197 |
|
| 198 |
|
| 199 |
|
| 200 |
conn.execute_batch("CREATE TABLE app_config (key TEXT PRIMARY KEY, value TEXT NOT NULL);") |
| 201 |
.unwrap(); |
| 202 |
let store = ConfigStore::attached(&SPEC); |
| 203 |
assert_eq!(store.get(&conn, "theme").unwrap(), None); |
| 204 |
store.set(&conn, "theme", "akari-night").unwrap(); |
| 205 |
assert_eq!( |
| 206 |
store.get(&conn, "theme").unwrap().as_deref(), |
| 207 |
Some("akari-night"), |
| 208 |
); |
| 209 |
} |
| 210 |
|
| 211 |
|
| 212 |
|
| 213 |
#[test] |
| 214 |
fn attached_to_a_missing_table_errors_on_use() { |
| 215 |
let conn = Connection::open_in_memory().unwrap(); |
| 216 |
let store = ConfigStore::attached(&SPEC); |
| 217 |
assert!(store.get(&conn, "theme").is_err(), "no such table"); |
| 218 |
} |
| 219 |
|
| 220 |
#[test] |
| 221 |
fn all_is_ordered_by_key() { |
| 222 |
let (conn, store) = store(); |
| 223 |
store.set(&conn, "theme", "flatwhite").unwrap(); |
| 224 |
store.set(&conn, "mirror_path", "/mnt/x").unwrap(); |
| 225 |
assert_eq!( |
| 226 |
store.all(&conn).unwrap(), |
| 227 |
vec![ |
| 228 |
("mirror_path".to_string(), "/mnt/x".to_string()), |
| 229 |
("theme".to_string(), "flatwhite".to_string()), |
| 230 |
], |
| 231 |
); |
| 232 |
} |
| 233 |
|
| 234 |
|
| 235 |
|
| 236 |
|
| 237 |
#[test] |
| 238 |
fn the_store_holds_local_and_synced_keys_alike() { |
| 239 |
let (conn, store) = store(); |
| 240 |
store.set(&conn, "mirror_path", "/mnt/samples").unwrap(); |
| 241 |
assert_eq!( |
| 242 |
store.get(&conn, "mirror_path").unwrap().as_deref(), |
| 243 |
Some("/mnt/samples"), |
| 244 |
"a Local key is still stored locally; posture governs sync, not storage", |
| 245 |
); |
| 246 |
} |
| 247 |
} |
| 248 |
|