diff --git a/Cargo.lock b/Cargo.lock index 3d695cbfe..bdd220039 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -288,6 +288,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "ahash" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "739f4a8db6605981345c5654f3a85b056ce52f37a39d34da03f25bf2151ea16e" + [[package]] name = "aho-corasick" version = "0.7.15" @@ -785,6 +791,18 @@ dependencies = [ "termcolor", ] +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "flate2" version = "1.0.20" @@ -1002,6 +1020,18 @@ name = "hashbrown" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7afe4a420e3fe79967a00898cc1f4db7c8a49a9333a29f8a4bd76a253d5cd04" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d99cf782f0dc4372d26846bec3de7804ceb5df083c2d4462c0b8d2330e894fa8" +dependencies = [ + "hashbrown", +] [[package]] name = "heck" @@ -1175,6 +1205,17 @@ version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9385f66bf6105b241aa65a61cb923ef20efc665cb9f9bb50ac2f0c4b7f378d41" +[[package]] +name = "libsqlite3-sys" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19cb1effde5f834799ac5e5ef0e40d45027cd74f271b1de786ba8abb30e2164d" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linked-hash-map" version = "0.5.4" @@ -1868,6 +1909,21 @@ dependencies = [ "serde", ] +[[package]] +name = "rusqlite" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc783b7ddae608338003bac1fa00b6786a75a9675fbd8e87243ecfdea3f6ed2" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "memchr", + "smallvec", +] + [[package]] name = "rust-argon2" version = "0.8.3" @@ -2154,6 +2210,7 @@ dependencies = [ "lmdb-rkv 0.14.0", "log", "proptest", + "rusqlite", "serde", "serde_json", "tempfile", @@ -2589,6 +2646,12 @@ dependencies = [ "serde", ] +[[package]] +name = "vcpkg" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbdbff6266a24120518560b5dc983096efb98462e51d0d68169895b237be3e5d" + [[package]] name = "vec_map" version = "0.8.2" diff --git a/taskchampion/Cargo.toml b/taskchampion/Cargo.toml index 2505d1ef9..e5069cea6 100644 --- a/taskchampion/Cargo.toml +++ b/taskchampion/Cargo.toml @@ -22,6 +22,7 @@ lmdb-rkv = {version = "^0.14.0"} ureq = "^2.1.0" log = "^0.4.14" tindercrypt = { version = "^0.2.2", default-features = false } +rusqlite = { version = "0.25", features = ["bundled"] } [dev-dependencies] proptest = "^1.0.0" diff --git a/taskchampion/src/storage/mod.rs b/taskchampion/src/storage/mod.rs index b263fd56f..b4993b039 100644 --- a/taskchampion/src/storage/mod.rs +++ b/taskchampion/src/storage/mod.rs @@ -12,6 +12,7 @@ use uuid::Uuid; mod config; mod inmemory; mod kv; +mod sqlite; mod operation; pub use self::kv::KvStorage; diff --git a/taskchampion/src/storage/sqlite.rs b/taskchampion/src/storage/sqlite.rs new file mode 100644 index 000000000..3992ae091 --- /dev/null +++ b/taskchampion/src/storage/sqlite.rs @@ -0,0 +1,495 @@ +use crate::storage::{Operation, Storage, StorageTxn, TaskMap, VersionId, DEFAULT_BASE_VERSION}; +use crate::utils::Key; +use anyhow::Context; +use rusqlite::Connection; +use serde::serde_if_integer128; +use std::path::Path; +use uuid::Uuid; + +#[derive(Debug, thiserror::Error)] +enum SqliteError { + #[error("SQLite transaction already committted")] + TransactionAlreadyCommitted, +} + +/// SqliteStorage is an on-disk storage backed by SQLite3. +pub struct SqliteStorage { + con: Connection, +} + +impl SqliteStorage { + pub fn new>(directory: P) -> anyhow::Result { + let db_file = directory.as_ref().join("taskchampion.sqlite3"); + let con = Connection::open(db_file)?; + con.execute( + "CREATE TABLE IF NOT EXISTS tasks (uuid STRING PRIMARY KEY, data STRING)", + [], + ) + .context("Creating table")?; + Ok(SqliteStorage { con }) + } +} + +struct Txn<'t> { + txn: Option>, +} + +impl<'t> Txn<'t> { + fn get_txn(&self) -> Result<&rusqlite::Transaction<'t>, SqliteError> { + self.txn + .as_ref() + .ok_or_else(|| SqliteError::TransactionAlreadyCommitted) + } +} + +impl Storage for SqliteStorage { + fn txn<'a>(&'a mut self) -> anyhow::Result> { + let txn = self.con.transaction()?; + Ok(Box::new(Txn { txn: Some(txn) })) + } +} + +impl<'t> StorageTxn for Txn<'t> { + fn get_task(&mut self, uuid: Uuid) -> anyhow::Result> { + let t = self.get_txn()?; + let result: Result = t.query_row( + "SELECT data FROM tasks WHERE uuid = ? LIMIT 1", + [&uuid.to_string()], + |r| r.get(0), + ); + + match result { + Ok(ref r) => { + let tm = serde_json::from_str(&r)?; + Ok(Some(tm)) + } + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(anyhow::Error::from(e)), + } + } + + fn create_task(&mut self, uuid: Uuid) -> anyhow::Result { + let t = self.get_txn()?; + let count: usize = t.query_row( + "SELECT count(uuid) FROM tasks WHERE uuid = ?", + [&uuid.to_string()], + |x| x.get(0), + )?; + if count > 0 { + return Ok(false); + } + + let data = TaskMap::default(); + let data_str = serde_json::to_string(&data)?; + t.execute( + "INSERT INTO TASKS (uuid, data) VALUES (?, ?)", + [&uuid.to_string(), &data_str], + ) + .context("Create task query")?; + Ok(true) + } + + fn set_task(&mut self, uuid: Uuid, task: TaskMap) -> anyhow::Result<()> { + let t = self.get_txn()?; + let data_str = serde_json::to_string(&task)?; + t.execute( + "INSERT OR REPLACE INTO tasks (uuid, data) VALUES (?, ?)", + [&uuid.to_string(), &data_str], + ) + .context("Update task query")?; + Ok(()) + } + + fn delete_task(&mut self, uuid: Uuid) -> anyhow::Result { + let t = self.get_txn()?; + let changed = t + .execute("DELETE FROM tasks WHERE uuid = ?", [&uuid.to_string()]) + .context("Delete task query")?; + Ok(changed > 0) + } + + fn all_tasks(&mut self) -> anyhow::Result> { + todo!() + } + + fn all_task_uuids(&mut self) -> anyhow::Result> { + todo!() + } + + fn base_version(&mut self) -> anyhow::Result { + todo!() + } + + fn set_base_version(&mut self, version: VersionId) -> anyhow::Result<()> { + todo!() + } + + fn operations(&mut self) -> anyhow::Result> { + todo!() + } + + fn add_operation(&mut self, op: Operation) -> anyhow::Result<()> { + todo!() + } + + fn set_operations(&mut self, ops: Vec) -> anyhow::Result<()> { + todo!() + } + + fn get_working_set(&mut self) -> anyhow::Result>> { + todo!() + } + + fn add_to_working_set(&mut self, uuid: Uuid) -> anyhow::Result { + todo!() + } + + fn set_working_set_item(&mut self, index: usize, uuid: Option) -> anyhow::Result<()> { + todo!() + } + + fn clear_working_set(&mut self) -> anyhow::Result<()> { + todo!() + } + + fn commit(&mut self) -> anyhow::Result<()> { + let t = self + .txn + .take() + .ok_or(SqliteError::TransactionAlreadyCommitted)?; + t.commit().context("Committing transaction")?; + Ok(()) + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::storage::taskmap_with; + use tempfile::TempDir; + + #[test] + fn test_create() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut storage = SqliteStorage::new(&tmp_dir.path())?; + let uuid = Uuid::new_v4(); + { + let mut txn = storage.txn()?; + assert!(txn.create_task(uuid)?); + txn.commit()?; + } + { + let mut txn = storage.txn()?; + let task = txn.get_task(uuid)?; + assert_eq!(task, Some(taskmap_with(vec![]))); + } + Ok(()) + } + + #[test] + fn test_create_exists() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut storage = SqliteStorage::new(&tmp_dir.path())?; + let uuid = Uuid::new_v4(); + { + let mut txn = storage.txn()?; + assert!(txn.create_task(uuid)?); + txn.commit()?; + } + { + let mut txn = storage.txn()?; + assert!(!txn.create_task(uuid)?); + txn.commit()?; + } + Ok(()) + } + + #[test] + fn test_get_missing() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut storage = SqliteStorage::new(&tmp_dir.path())?; + let uuid = Uuid::new_v4(); + { + let mut txn = storage.txn()?; + let task = txn.get_task(uuid)?; + assert_eq!(task, None); + } + Ok(()) + } + + #[test] + fn test_set_task() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut storage = SqliteStorage::new(&tmp_dir.path())?; + let uuid = Uuid::new_v4(); + { + let mut txn = storage.txn()?; + txn.set_task(uuid, taskmap_with(vec![("k".to_string(), "v".to_string())]))?; + txn.commit()?; + } + { + let mut txn = storage.txn()?; + let task = txn.get_task(uuid)?; + assert_eq!( + task, + Some(taskmap_with(vec![("k".to_string(), "v".to_string())])) + ); + } + Ok(()) + } + + #[test] + fn test_delete_task_missing() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut storage = SqliteStorage::new(&tmp_dir.path())?; + let uuid = Uuid::new_v4(); + { + let mut txn = storage.txn()?; + assert!(!txn.delete_task(uuid)?); + } + Ok(()) + } + + #[test] + fn test_delete_task_exists() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut storage = SqliteStorage::new(&tmp_dir.path())?; + let uuid = Uuid::new_v4(); + { + let mut txn = storage.txn()?; + assert!(txn.create_task(uuid)?); + txn.commit()?; + } + { + let mut txn = storage.txn()?; + assert!(txn.delete_task(uuid)?); + } + Ok(()) + } + + #[test] + fn test_all_tasks_empty() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut storage = SqliteStorage::new(&tmp_dir.path())?; + { + let mut txn = storage.txn()?; + let tasks = txn.all_tasks()?; + assert_eq!(tasks, vec![]); + } + Ok(()) + } + + #[test] + fn test_all_tasks_and_uuids() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut storage = SqliteStorage::new(&tmp_dir.path())?; + let uuid1 = Uuid::new_v4(); + let uuid2 = Uuid::new_v4(); + { + let mut txn = storage.txn()?; + assert!(txn.create_task(uuid1.clone())?); + txn.set_task( + uuid1.clone(), + taskmap_with(vec![("num".to_string(), "1".to_string())]), + )?; + assert!(txn.create_task(uuid2.clone())?); + txn.set_task( + uuid2.clone(), + taskmap_with(vec![("num".to_string(), "2".to_string())]), + )?; + txn.commit()?; + } + { + let mut txn = storage.txn()?; + let mut tasks = txn.all_tasks()?; + + // order is nondeterministic, so sort by uuid + tasks.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut exp = vec![ + ( + uuid1.clone(), + taskmap_with(vec![("num".to_string(), "1".to_string())]), + ), + ( + uuid2.clone(), + taskmap_with(vec![("num".to_string(), "2".to_string())]), + ), + ]; + exp.sort_by(|a, b| a.0.cmp(&b.0)); + + assert_eq!(tasks, exp); + } + { + let mut txn = storage.txn()?; + let mut uuids = txn.all_task_uuids()?; + uuids.sort(); + + let mut exp = vec![uuid1.clone(), uuid2.clone()]; + exp.sort(); + + assert_eq!(uuids, exp); + } + Ok(()) + } + + #[test] + fn test_base_version_default() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut storage = SqliteStorage::new(&tmp_dir.path())?; + { + let mut txn = storage.txn()?; + assert_eq!(txn.base_version()?, DEFAULT_BASE_VERSION); + } + Ok(()) + } + + #[test] + fn test_base_version_setting() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut storage = SqliteStorage::new(&tmp_dir.path())?; + let u = Uuid::new_v4(); + { + let mut txn = storage.txn()?; + txn.set_base_version(u)?; + txn.commit()?; + } + { + let mut txn = storage.txn()?; + assert_eq!(txn.base_version()?, u); + } + Ok(()) + } + + #[test] + fn test_operations() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut storage = SqliteStorage::new(&tmp_dir.path())?; + let uuid1 = Uuid::new_v4(); + let uuid2 = Uuid::new_v4(); + let uuid3 = Uuid::new_v4(); + + // create some operations + { + let mut txn = storage.txn()?; + txn.add_operation(Operation::Create { uuid: uuid1 })?; + txn.add_operation(Operation::Create { uuid: uuid2 })?; + txn.commit()?; + } + + // read them back + { + let mut txn = storage.txn()?; + let ops = txn.operations()?; + assert_eq!( + ops, + vec![ + Operation::Create { uuid: uuid1 }, + Operation::Create { uuid: uuid2 }, + ] + ); + } + + // set them to a different bunch + { + let mut txn = storage.txn()?; + txn.set_operations(vec![ + Operation::Delete { uuid: uuid2 }, + Operation::Delete { uuid: uuid1 }, + ])?; + txn.commit()?; + } + + // create some more operations (to test adding operations after clearing) + { + let mut txn = storage.txn()?; + txn.add_operation(Operation::Create { uuid: uuid3 })?; + txn.add_operation(Operation::Delete { uuid: uuid3 })?; + txn.commit()?; + } + + // read them back + { + let mut txn = storage.txn()?; + let ops = txn.operations()?; + assert_eq!( + ops, + vec![ + Operation::Delete { uuid: uuid2 }, + Operation::Delete { uuid: uuid1 }, + Operation::Create { uuid: uuid3 }, + Operation::Delete { uuid: uuid3 }, + ] + ); + } + Ok(()) + } + + #[test] + fn get_working_set_empty() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut storage = SqliteStorage::new(&tmp_dir.path())?; + + { + let mut txn = storage.txn()?; + let ws = txn.get_working_set()?; + assert_eq!(ws, vec![None]); + } + + Ok(()) + } + + #[test] + fn add_to_working_set() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut storage = SqliteStorage::new(&tmp_dir.path())?; + let uuid1 = Uuid::new_v4(); + let uuid2 = Uuid::new_v4(); + + { + let mut txn = storage.txn()?; + txn.add_to_working_set(uuid1)?; + txn.add_to_working_set(uuid2)?; + txn.commit()?; + } + + { + let mut txn = storage.txn()?; + let ws = txn.get_working_set()?; + assert_eq!(ws, vec![None, Some(uuid1), Some(uuid2)]); + } + + Ok(()) + } + + #[test] + fn clear_working_set() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut storage = SqliteStorage::new(&tmp_dir.path())?; + let uuid1 = Uuid::new_v4(); + let uuid2 = Uuid::new_v4(); + + { + let mut txn = storage.txn()?; + txn.add_to_working_set(uuid1)?; + txn.add_to_working_set(uuid2)?; + txn.commit()?; + } + + { + let mut txn = storage.txn()?; + txn.clear_working_set()?; + txn.add_to_working_set(uuid2)?; + txn.add_to_working_set(uuid1)?; + txn.commit()?; + } + + { + let mut txn = storage.txn()?; + let ws = txn.get_working_set()?; + assert_eq!(ws, vec![None, Some(uuid2), Some(uuid1)]); + } + + Ok(()) + } +}