use std::{collections::HashSet, path::Path}; use chrono::NaiveDateTime; use rusqlite::{params, params_from_iter, Connection, ToSql}; use uuid::Uuid; use crate::{ error::DatabaseOpenError, task::{Category, Log, Task}, Result, }; struct Db(Connection); impl Db { pub fn create_connect_and_migrate( path: impl AsRef, ) -> std::result::Result { if let Some(dir) = path.as_ref().parent() { if dir.is_dir() { } else { std::fs::create_dir_all(dir)?; } let _file = std::fs::OpenOptions::new() .append(true) .create(true) .open(path.as_ref())?; } let url = format!( "{}", path.as_ref() .to_str() .ok_or(DatabaseOpenError::InvalidPath)? ); // let db = SqlitePool::connect(&url).await?; // migrate!().run(&db).await?; // Ok(Self { db }) let db = Connection::open(url)?; db.execute_batch(include_str!("../migrations/1.sql"))?; Ok(Self(db)) } pub fn create_connect_memory() -> std::result::Result { let db = Connection::open_in_memory()?; db.execute_batch(include_str!("../migrations/1.sql"))?; Ok(Self(db)) } pub fn create_task(&self, task: &Task) -> Result<()> { let task_id = Uuid::new_v4(); self.0.execute( "insert into tasks ( id, name, cron, archived, description ) values ( ?1, ?2, ?3, ?4, ?5 )", (task_id, &task.name, &task.cron, task.archived, &task.description), )?; for category in &task.categories { let category_id = Uuid::new_v4(); self.0.execute( "insert or ignore into categories ( id, name ) values ( ?1, ?2 )", (category_id, category), )?; self.0.execute( "insert into tasks_categories ( task_id, category_id ) values ( ?1, ?2 )", [task_id, category_id], )?; } Ok(()) } pub fn read_task(&self, name: impl AsRef) -> Result { let name = name.as_ref(); let (task_id, mut task) = self .0 .prepare("select id, name, cron, archived, description from tasks where name = ?1")? .query_one([name], |row| { Ok(( row.get::<_, Uuid>(0)?, Task { name: row.get(1)?, cron: row.get(2)?, archived: row.get(3)?, description: row.get(4)?, categories: HashSet::new(), }, )) })?; task.categories = self .0 .prepare("select name from tasks_categories join categories where task_id = ?1")? .query_map([task_id], |row| Ok(Category::new(row.get(0)?)))? .collect::, _>>()?; Ok(task) } // TODO: filter pub fn read_tasks(&self, filter: Vec) -> Result> { let mut tasks = self .0 .prepare("select name, cron, archived, description, id from tasks")? .query_map([], |row| { Ok(( row.get::<_, Uuid>(4)?, Task { name: row.get(0)?, cron: row.get(1)?, archived: row.get(2)?, description: row.get(3)?, categories: HashSet::new(), }, )) })? .collect::, _>>()?; for (task_id, task) in &mut tasks { let categories = self .0 .prepare("select name from tasks_categories join categories where task_id = ?1")? .query_map([*task_id], |row| Ok(Category::new(row.get(0)?)))? .collect::, _>>()?; task.categories = categories; } Ok(tasks.into_iter().map(|(_id, task)| task).collect()) } pub fn update_task(&self, name: impl AsRef, updated_task: &Task) -> Result<()> { let task_id = self.0 .prepare("update tasks set name = ?1, cron = ?2, archived = ?3, description = ?4 where name = ?5 returning id")?.query_one( (&updated_task.name, &updated_task.cron, updated_task.archived, &updated_task.description, name.as_ref()), |row| { Ok(row.get::<_, Uuid>(0)?) })?; self.0 .execute("delete from tasks_categories where task_id = ?1", [task_id])?; // TODO: clean up orphaned groups.... for category in &updated_task.categories { let category_id = Uuid::new_v4(); self.0.execute( "insert or ignore into categories ( id, name ) values ( ?1, ?2 )", (category_id, category), )?; self.0.execute( "insert into tasks_categories ( task_id, category_id ) values ( ?1, ?2 )", [task_id, category_id], )?; } Ok(()) } pub fn delete_task(&self, name: impl AsRef) -> Result<()> { self.0 .execute("delete from tasks where name = ?1", [name.as_ref()])?; Ok(()) } pub fn create_log(&self, task_name: impl AsRef, log: &Log) -> Result<()> { self.0.execute("insert into log ( id, task_id, timestamp ) values ( ?1, (select id from tasks where name = ?2), ?3 )", (log.id, task_name.as_ref(), log.timestamp))?; Ok(()) } pub fn read_logs(&self, filter: Vec) -> Result> { let mut query = "select id, timestamp from log".to_string(); if !filter.is_empty() { query.push_str(" where ") } query.push_str( &filter .iter() .map(|filter| filter.where_clause()) .collect::>() .join(", "), ); let logs = self .0 .prepare(&query)? .query_map(params_from_iter(filter), |row| { Ok(Log { id: row.get(0)?, timestamp: row.get(1)?, }) })? .collect::, _>>()?; Ok(logs) } pub fn delete_log(&self, log: &Log) -> Result<()> { self.0.execute("delete from logs where id = ?1", [log.id])?; Ok(()) } } pub enum TaskFilter { Archived(bool), Category(String), } impl ToSql for TaskFilter { fn to_sql(&self) -> rusqlite::Result> { match self { TaskFilter::Archived(a) => a.to_sql(), TaskFilter::Category(c) => c.to_sql(), } } } pub enum LogFilter { Task(String), Before(NaiveDateTime), After(NaiveDateTime), } impl LogFilter { fn where_clause(&self) -> &'static str { match self { LogFilter::Task(_) => "task_id = (select id from tasks where name = ?)", LogFilter::Before(naive_date_time) => "timestamp < ?", LogFilter::After(naive_date_time) => "timestamp > ?", } } } impl ToSql for LogFilter { fn to_sql(&self) -> rusqlite::Result> { match self { LogFilter::Task(n) => n.to_sql(), LogFilter::Before(naive_date_time) => naive_date_time.to_sql(), LogFilter::After(naive_date_time) => naive_date_time.to_sql(), } } } #[cfg(test)] mod tests { use std::collections::HashSet; use crate::task::{Category, Task}; use super::Db; #[test] fn create_task() { let db = Db::create_connect_memory().unwrap(); let task = Task::new( "piano".to_string(), None, Some("practice piano".to_string()), Some(HashSet::from([Category::new("music".to_string())])), ); db.create_task(&task).unwrap(); } #[test] fn read_task() { let db = Db::create_connect_memory().unwrap(); let task = Task::new( "piano".to_string(), None, Some("practice piano".to_string()), Some(HashSet::from([Category::new("music".to_string())])), ); db.create_task(&task).unwrap(); assert_eq!(task, db.read_task("piano").unwrap()); } #[test] fn read_tasks() { let db = Db::create_connect_memory().unwrap(); let piano = Task::new( "piano".to_string(), None, Some("practice piano".to_string()), Some(HashSet::from([Category::new("music".to_string())])), ); db.create_task(&piano).unwrap(); let german = Task::new( "german".to_string(), None, Some("study german".to_string()), None, ); db.create_task(&german).unwrap(); assert_eq!(vec![piano, german], db.read_tasks(Vec::new()).unwrap()); } // TODO: task orderings: by day, by week, by what's most urgent. #[test] fn filter_tasks() {} #[test] fn update_task() {} #[test] fn delete_task() {} #[test] fn create_log() {} #[test] fn read_logs() {} #[test] fn filter_logs() {} #[test] fn delete_log() {} }