From 0312481605c08377a9fcb9a3c01a7c2f663ae8f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C5=91rinc=20Serf=C5=91z=C5=91?= Date: Tue, 12 Dec 2023 22:03:48 +0100 Subject: [PATCH] Implemented add and hide_category in SQLite datafile --- .gitignore | 1 + src/datafile.rs | 35 ++++ src/datafile/csv_datafile.rs | 12 ++ src/datafile/sqlite_datafile.rs | 348 +++++++++++++++++++++++++++----- 4 files changed, 341 insertions(+), 55 deletions(-) diff --git a/.gitignore b/.gitignore index ea8c4bf..e4ddc83 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +test.db* diff --git a/src/datafile.rs b/src/datafile.rs index ee4ac45..85f1179 100644 --- a/src/datafile.rs +++ b/src/datafile.rs @@ -17,8 +17,36 @@ pub enum SuccessfulUpdate { ReplacedExisting, } +/// Result from the call to `add_category` +#[derive(Debug, PartialEq)] +pub enum AddCategoryResult { + /// Created a new category + AddedNew, + + /// Made a previously hidden category visible again + Unhide, + + /// The category is already present and is visible + AlreadyPresent, +} + +/// Result from the call to `hide_category` +#[derive(Debug, PartialEq)] +pub enum HideCategoryResult { + /// The specified category was visible previously and was hidden + Hidden, + + /// The specified category is already hidden, nothing was changed + AlreadyHidden, + + /// The specified category does not exist + NonExistingCategory, +} + /// Represents a connection to the diary database. pub trait DiaryDataConnection { + fn into_any(self: Box) -> Box; + /// Calculates the occurences of all habits over multiple periods of date ranges. fn calculate_data_counts_per_iter( &self, @@ -47,7 +75,14 @@ pub trait DiaryDataConnection { /// Returns if the database contains any records. fn is_empty(&self) -> Result; + /// Returns the earliest and latest date among the database records. fn get_date_range(&self) -> Result<(NaiveDate, NaiveDate)>; + + /// Adds or unhides the specified category in the database. + fn add_category(&self, name: &str) -> Result; + + /// Hides the specified category in the database. + fn hide_category(&self, name: &str) -> Result; } /// Tries to read data file to memory. diff --git a/src/datafile/csv_datafile.rs b/src/datafile/csv_datafile.rs index e3ee053..d909867 100644 --- a/src/datafile/csv_datafile.rs +++ b/src/datafile/csv_datafile.rs @@ -87,6 +87,10 @@ fn calculate_data_counts(data: &DiaryDataCsv, from: &NaiveDate, to: &NaiveDate) } impl DiaryDataConnection for DiaryDataCsv { + fn into_any(self: Box) -> Box { + self + } + fn calculate_data_counts_per_iter( &self, date_ranges: &[(NaiveDate, NaiveDate)], @@ -157,6 +161,14 @@ impl DiaryDataConnection for DiaryDataCsv { *self.data.last_key_value().unwrap().0, )) } + + fn add_category(&self, _name: &str) -> Result { + todo!(); + } + + fn hide_category(&self, _name: &str) -> Result { + todo!(); + } } impl Drop for DiaryDataCsv { diff --git a/src/datafile/sqlite_datafile.rs b/src/datafile/sqlite_datafile.rs index ebbe38b..9425489 100644 --- a/src/datafile/sqlite_datafile.rs +++ b/src/datafile/sqlite_datafile.rs @@ -10,15 +10,32 @@ struct DiaryDataSqlite { connection: Connection, } +const CURRENT_DB_VERSION: usize = 1; + +fn insert_version_to_db(conn: &Connection) -> Result<()> { + conn.execute( + "INSERT INTO Info (info_name, info_value) VALUES (\"version\", ?1)", + params![CURRENT_DB_VERSION], + )?; + Ok(()) +} + pub fn create_new_sqlite(path: &Path, headers: &[String]) -> Result<()> { let conn = Connection::open(path).context("Could not open/create SQLite database")?; conn.execute_batch( "BEGIN; + DROP TABLE IF EXISTS Info; + CREATE TABLE Info( + info_id INTEGER PRIMARY KEY AUTOINCREMENT, + info_name TEXT UNIQUE NOT NULL, + info_value TEXT NOT NULL + ); DROP TABLE IF EXISTS Category; CREATE TABLE Category( category_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, - created_at INTEGER NOT NULL + created_at INTEGER NOT NULL, + hidden INTEGER NOT NULL ); DROP TABLE IF EXISTS DateEntry; CREATE TABLE DateEntry( @@ -33,10 +50,11 @@ pub fn create_new_sqlite(path: &Path, headers: &[String]) -> Result<()> { ); COMMIT;", )?; + insert_version_to_db(&conn)?; let now = chrono::Local::now().timestamp(); for header in headers { conn.execute( - "INSERT INTO Category (name, created_at) VALUES (?1, ?2)", + "INSERT INTO Category (name, created_at, hidden) VALUES (?1, ?2, 0)", params![header, now], )?; } @@ -59,10 +77,22 @@ pub fn open_sqlite_datafile(path: &Path) -> Result> .run_to_completion(10, std::time::Duration::default(), None) .context("Could not perform backup")?; } + let db_version = data.get_db_version()?; + if db_version < CURRENT_DB_VERSION { + println!( + "Detected an SQLite datafile of version {}. Commencing update...", + db_version + ); + data.update_db()?; + } Ok(Box::new(data)) } impl DiaryDataConnection for DiaryDataSqlite { + fn into_any(self: Box) -> Box { + self + } + fn calculate_data_counts_per_iter( &self, date_ranges: &[(chrono::NaiveDate, chrono::NaiveDate)], @@ -149,7 +179,7 @@ impl DiaryDataConnection for DiaryDataSqlite { fn get_header(&self) -> Result> { let mut statement = self .connection - .prepare("SELECT name FROM Category ORDER BY category_id")?; + .prepare("SELECT name FROM Category WHERE hidden=0 ORDER BY category_id")?; let rows = statement.query_map([], |row| row.get(0))?; let mut ret = vec![]; @@ -162,7 +192,7 @@ impl DiaryDataConnection for DiaryDataSqlite { fn get_row(&self, date: &chrono::NaiveDate) -> Result>> { let mut statement = self .connection - .prepare("SELECT category_id FROM Category ORDER BY category_id")?; + .prepare("SELECT category_id FROM Category WHERE hidden=0 ORDER BY category_id")?; let rows = statement.query_map([], |row| row.get(0))?; // Ordered list of all category IDs in the database @@ -224,6 +254,57 @@ impl DiaryDataConnection for DiaryDataSqlite { Ok((min_date, max_date)) } + + fn add_category(&self, name: &str) -> Result { + let mut statement = self + .connection + .prepare("SELECT category_id, hidden FROM Category WHERE name=(?1)")?; + let mut rows = statement.query(params![name])?; + + if let Some(row) = rows.next()? { + let category_id: usize = row.get(0)?; + let hidden = 0usize != row.get(1)?; + + if hidden { + let mut statement = self + .connection + .prepare("UPDATE Category SET hidden=0 WHERE category_id=(?1)")?; + statement.execute(params![category_id])?; + Ok(super::AddCategoryResult::Unhide) + } else { + Ok(super::AddCategoryResult::AlreadyPresent) + } + } else { + let mut statement = self + .connection + .prepare("INSERT INTO Category (name, created_at, hidden) VALUES (?1, ?2, 0)")?; + let now = chrono::Local::now().timestamp(); + statement.execute(params![name, now])?; + Ok(super::AddCategoryResult::AddedNew) + } + } + + fn hide_category(&self, name: &str) -> Result { + let mut statement = self + .connection + .prepare("SELECT category_id, hidden FROM Category WHERE name=(?1)")?; + let mut rows = statement.query(params![name])?; + if let Some(row) = rows.next()? { + let category_id: usize = row.get(0)?; + let hidden = 0usize != row.get(1)?; + if hidden { + Ok(super::HideCategoryResult::AlreadyHidden) + } else { + let mut statement = self + .connection + .prepare("UPDATE Category SET hidden=1 WHERE category_id=(?1)")?; + statement.execute(params![category_id])?; + Ok(super::HideCategoryResult::Hidden) + } + } else { + Ok(super::HideCategoryResult::NonExistingCategory) + } + } } impl DiaryDataSqlite { @@ -308,65 +389,222 @@ impl DiaryDataSqlite { Ok(super::SuccessfulUpdate::ReplacedExisting) } } + + fn get_db_version(&self) -> Result { + if let Ok(mut statement) = self + .connection + .prepare("SELECT info_value FROM Info WHERE info_name=\"version\"") + { + let version: Result = + statement.query_row([], |row| row.get(0)); + version + .map(|str| Ok(str.parse().unwrap_or(0))) + .unwrap_or(Ok(0)) + } else { + Ok(0) + } + } + + fn update_db_to_v1(&self) -> Result<()> { + println!("- Updating SQLite datafile to version 1..."); + self.connection.execute_batch( + "BEGIN; + DROP TABLE IF EXISTS Info; + CREATE TABLE Info( + info_id INTEGER PRIMARY KEY AUTOINCREMENT, + info_name TEXT UNIQUE NOT NULL, + info_value TEXT NOT NULL + ); + ALTER TABLE Category ADD COLUMN hidden INTEGER NOT NULL; + COMMIT;", + )?; + insert_version_to_db(&self.connection)?; + println!("- Success"); + Ok(()) + } + + fn update_db(&self) -> Result<()> { + self.update_db_to_v1()?; + Ok(()) + } } -#[test] -fn test_sqlite() { - create_new_sqlite( - Path::new("test.db"), - &[String::from("AA"), String::from("BBB"), String::from("CCA")], - ) - .unwrap(); - let mut datafile = open_sqlite_datafile(Path::new("test.db")).unwrap(); - datafile - .update_data( - &chrono::NaiveDate::from_ymd_opt(2023, 2, 4).unwrap(), - &[false, true, false], +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn current_database_version() { + create_new_sqlite( + Path::new("test.db"), + &[String::from("AA"), String::from("BBB"), String::from("CCA")], ) .unwrap(); - datafile - .update_data( - &chrono::NaiveDate::from_ymd_opt(2023, 3, 3).unwrap(), - &[false, true, false], + let datafile = open_sqlite_datafile(Path::new("test.db")).unwrap(); + + assert_eq!( + CURRENT_DB_VERSION, + datafile + .into_any() + .downcast::() + .unwrap() + .get_db_version() + .unwrap() + ); + } + + #[test] + fn database_update() { + { + let conn = Connection::open("test.db").unwrap(); + conn.execute_batch( + "BEGIN; + DROP TABLE IF EXISTS Info; + DROP TABLE IF EXISTS Category; + CREATE TABLE Category( + category_id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + DROP TABLE IF EXISTS DateEntry; + CREATE TABLE DateEntry( + date DATE PRIMARY KEY, + created_at INTEGER NOT NULL + ); + DROP TABLE IF EXISTS EntryToCategories; + CREATE TABLE EntryToCategories( + date INTEGER NOT NULL REFERENCES DateEntry(date) ON DELETE CASCADE, + category_id INTEGER NOT NULL REFERENCES Category(category_id) ON DELETE CASCADE, + PRIMARY KEY(category_id, date) + ); + COMMIT;", + ) + .unwrap(); + conn.close().unwrap(); + } + let datafile = open_sqlite_datafile(Path::new("test.db")).unwrap(); + + assert_eq!( + CURRENT_DB_VERSION, + datafile + .into_any() + .downcast::() + .unwrap() + .get_db_version() + .unwrap() + ); + } + + #[test] + fn test_sqlite() { + create_new_sqlite( + Path::new("test.db"), + &[String::from("AA"), String::from("BBB"), String::from("CCA")], ) .unwrap(); - datafile - .update_data( - &chrono::NaiveDate::from_ymd_opt(2023, 2, 7).unwrap(), - &[false, false, true], + let mut datafile = open_sqlite_datafile(Path::new("test.db")).unwrap(); + datafile + .update_data( + &chrono::NaiveDate::from_ymd_opt(2023, 2, 4).unwrap(), + &[false, true, false], + ) + .unwrap(); + datafile + .update_data( + &chrono::NaiveDate::from_ymd_opt(2023, 3, 3).unwrap(), + &[false, true, false], + ) + .unwrap(); + datafile + .update_data( + &chrono::NaiveDate::from_ymd_opt(2023, 2, 7).unwrap(), + &[false, false, true], + ) + .unwrap(); + let missing_dates = datafile + .get_missing_dates( + &None, + &chrono::NaiveDate::from_ymd_opt(2023, 2, 10).unwrap(), + ) + .unwrap(); + assert_eq!( + missing_dates, + vec![ + NaiveDate::from_ymd_opt(2023, 2, 5).unwrap(), + NaiveDate::from_ymd_opt(2023, 2, 6).unwrap(), + NaiveDate::from_ymd_opt(2023, 2, 8).unwrap(), + NaiveDate::from_ymd_opt(2023, 2, 9).unwrap(), + NaiveDate::from_ymd_opt(2023, 2, 10).unwrap(), + ] + ); + let data_counts = datafile + .calculate_data_counts_per_iter(&vec![( + NaiveDate::from_ymd_opt(2023, 3, 3).unwrap(), + NaiveDate::from_ymd_opt(2023, 2, 3).unwrap(), + )]) + .unwrap(); + assert_eq!(data_counts, vec![vec![0, 2, 1]]); + + let (min_date, max_date) = datafile.get_date_range().unwrap(); + assert_eq!( + min_date, + chrono::NaiveDate::from_ymd_opt(2023, 2, 4).unwrap() + ); + assert_eq!( + max_date, + chrono::NaiveDate::from_ymd_opt(2023, 3, 3).unwrap() + ); + } + + #[test] + fn add_category() { + use crate::datafile::AddCategoryResult; + + create_new_sqlite( + Path::new("test.db"), + &[String::from("AA"), String::from("BBB"), String::from("CCA")], ) .unwrap(); - let missing_dates = datafile - .get_missing_dates( - &None, - &chrono::NaiveDate::from_ymd_opt(2023, 2, 10).unwrap(), + let datafile = open_sqlite_datafile(Path::new("test.db")).unwrap(); + let result = datafile.add_category("BBB").unwrap(); + assert_eq!(AddCategoryResult::AlreadyPresent, result); + let result = datafile.add_category("DDD").unwrap(); + assert_eq!(AddCategoryResult::AddedNew, result); + + let header = datafile.get_header().unwrap(); + assert_eq!(vec!["AA", "BBB", "CCA", "DDD"], header); + } + + #[test] + fn hide_category() { + use crate::datafile::{AddCategoryResult, HideCategoryResult}; + + create_new_sqlite( + Path::new("test.db"), + &[String::from("AA"), String::from("BBB"), String::from("CCA")], ) .unwrap(); - assert_eq!( - missing_dates, - vec![ - NaiveDate::from_ymd_opt(2023, 2, 5).unwrap(), - NaiveDate::from_ymd_opt(2023, 2, 6).unwrap(), - NaiveDate::from_ymd_opt(2023, 2, 8).unwrap(), - NaiveDate::from_ymd_opt(2023, 2, 9).unwrap(), - NaiveDate::from_ymd_opt(2023, 2, 10).unwrap(), - ] - ); - let data_counts = datafile - .calculate_data_counts_per_iter(&vec![( - NaiveDate::from_ymd_opt(2023, 3, 3).unwrap(), - NaiveDate::from_ymd_opt(2023, 2, 3).unwrap(), - )]) - .unwrap(); - assert_eq!(data_counts, vec![vec![0, 2, 1]]); - - let (min_date, max_date) = datafile.get_date_range().unwrap(); - assert_eq!( - min_date, - chrono::NaiveDate::from_ymd_opt(2023, 2, 4).unwrap() - ); - assert_eq!( - max_date, - chrono::NaiveDate::from_ymd_opt(2023, 3, 3).unwrap() - ); + let datafile = open_sqlite_datafile(Path::new("test.db")).unwrap(); + + let result = datafile.hide_category("DDD").unwrap(); + assert_eq!(HideCategoryResult::NonExistingCategory, result); + + let result = datafile.hide_category("AA").unwrap(); + assert_eq!(HideCategoryResult::Hidden, result); + + let header = datafile.get_header().unwrap(); + assert_eq!(vec!["BBB", "CCA"], header); + + let result = datafile.hide_category("AA").unwrap(); + assert_eq!(HideCategoryResult::AlreadyHidden, result); + + let header = datafile.get_header().unwrap(); + assert_eq!(vec!["BBB", "CCA"], header); + + let result = datafile.add_category("AA").unwrap(); + assert_eq!(AddCategoryResult::Unhide, result); + + let header = datafile.get_header().unwrap(); + assert_eq!(vec!["AA", "BBB", "CCA"], header); + } }