From 405f80a7fff3ac30267b6b63d9be3a74c307f0c4 Mon Sep 17 00:00:00 2001 From: simonsan <14062932+simonsan@users.noreply.github.com> Date: Mon, 25 Mar 2024 20:17:54 +0100 Subject: [PATCH] implement activity traits I Signed-off-by: simonsan <14062932+simonsan@users.noreply.github.com> --- Cargo.lock | 35 +- Cargo.toml | 6 +- crates/core/Cargo.toml | 9 +- crates/core/README.md | 5 +- crates/core/src/config.rs | 9 +- crates/core/src/domain/activity.rs | 19 + crates/core/src/domain/status.rs | 17 +- crates/core/src/error.rs | 45 +- crates/core/src/storage.rs | 44 +- crates/core/src/storage/sqlite.rs | 443 +++++++++++++++++- crates/time/Cargo.toml | 5 +- crates/time/README.md | 6 + crates/time/src/lib.rs | 2 + crates/time/src/rusqlite.rs | 39 ++ .../20240325143710_create_activities.sql | 7 +- 15 files changed, 615 insertions(+), 76 deletions(-) create mode 100644 crates/time/src/rusqlite.rs diff --git a/Cargo.lock b/Cargo.lock index 7d14c96b..72b767c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -490,38 +490,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "diesel" -version = "2.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03fc05c17098f21b89bc7d98fe1dd3cce2c11c2ad8e145f2a44fe08ed28eb559" -dependencies = [ - "diesel_derives", - "libsqlite3-sys", - "time", -] - -[[package]] -name = "diesel_derives" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d02eecb814ae714ffe61ddc2db2dd03e6c49a42e269b5001355500d431cce0c" -dependencies = [ - "diesel_table_macro_syntax", - "proc-macro2", - "quote", - "syn 2.0.55", -] - -[[package]] -name = "diesel_table_macro_syntax" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc5557efc453706fed5e4fa85006fe9817c224c3f480a34c7e5959fd700921c5" -dependencies = [ - "syn 2.0.55", -] - [[package]] name = "difflib" version = "0.4.0" @@ -1312,7 +1280,6 @@ dependencies = [ "clap_complete", "clap_complete_nushell", "dialoguer", - "diesel", "directories", "eyre", "human-panic", @@ -1404,6 +1371,7 @@ dependencies = [ "getset", "humantime", "rstest", + "rusqlite", "serde", "serde_derive", "thiserror", @@ -1820,6 +1788,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" dependencies = [ "bitflags 2.5.0", + "chrono", "fallible-iterator", "fallible-streaming-iterator", "hashlink", diff --git a/Cargo.toml b/Cargo.toml index 3be5f968..379fe552 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,6 @@ clap_complete = "4.5.1" clap_complete_nushell = "4.5.1" derive_more = { version = "0.99.17", default-features = false } dialoguer = "0.11.0" -diesel = "2.1.5" directories = "5.0.1" displaydoc = "0.2.4" dotenvy = "0.15.7" @@ -102,13 +101,12 @@ clap = { workspace = true, features = ["env", "wrap_help", "derive"] } clap_complete = { workspace = true } clap_complete_nushell = { workspace = true } dialoguer = { workspace = true, features = ["history", "fuzzy-select"] } -diesel = { workspace = true, features = ["sqlite"] } directories = { workspace = true } eyre = { workspace = true } human-panic = { workspace = true } pace_cli = { workspace = true } -pace_core = { workspace = true, features = ["cli"] } -pace_time = { workspace = true, features = ["cli"] } +pace_core = { workspace = true, features = ["cli", "db"] } +pace_time = { workspace = true, features = ["cli", "db"] } serde = { workspace = true } serde_derive = { workspace = true } thiserror = { workspace = true } diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 4e541ec6..62627969 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -20,9 +20,10 @@ include = [ ] [features] -default = ["cli"] +default = ["cli", "db"] cli = ["clap"] -sqlite = ["dep:rusqlite", "dep:libsqlite3-sys"] +db = ["rusqlite"] +rusqlite = ["dep:rusqlite", "dep:libsqlite3-sys"] clap = ["dep:clap"] [dependencies] @@ -40,10 +41,10 @@ merge = { workspace = true } miette = { workspace = true, features = ["fancy"] } once_cell = { workspace = true } open = { workspace = true } -pace_time = { workspace = true } +pace_time = { workspace = true, features = ["rusqlite"] } parking_lot = { workspace = true, features = ["deadlock_detection"] } rayon = { workspace = true } -rusqlite = { workspace = true, optional = true, features = ["bundled"] } +rusqlite = { workspace = true, optional = true, features = ["bundled", "chrono"] } serde = { workspace = true } serde_derive = { workspace = true } serde_json = { workspace = true } diff --git a/crates/core/README.md b/crates/core/README.md index 82260d40..75faa48c 100644 --- a/crates/core/README.md +++ b/crates/core/README.md @@ -42,9 +42,8 @@ This crate exposes a few features for controlling dependency usage: - **cli** - Enables support for CLI features by enabling `merge` and `clap` features. *This feature is enabled by default*. -- **sqlite** - Enables a dependency on the `rusqlite` crate and enables - persistence to a SQLite database. *This feature is disabled by default as it's - not yet implemented*. +- **rusqlite** - Enables a dependency on the `rusqlite` crate and enables + persistence to a SQLite database. *This feature is enabled by default*. ## Examples diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 3f212f12..fa687eac 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -195,14 +195,21 @@ pub struct ExportConfig { /// Default: `sqlite` /// /// Options: `sqlite`, `postgres`, `mysql`, `sql-server` -#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default)] +#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default, displaydoc::Display, EnumString)] #[serde(rename_all = "kebab-case")] #[non_exhaustive] pub enum DatabaseEngineKind { #[default] + /// SQLite Sqlite, + + /// Postgres Postgres, + + /// MySQL Mysql, + + /// SQL Server SqlServer, } diff --git a/crates/core/src/domain/activity.rs b/crates/core/src/domain/activity.rs index 6899fe13..9537e74f 100644 --- a/crates/core/src/domain/activity.rs +++ b/crates/core/src/domain/activity.rs @@ -8,6 +8,7 @@ use pace_time::{ date_time::PaceDateTime, duration::{calculate_duration, duration_to_str, PaceDuration}, }; + use serde_derive::{Deserialize, Serialize}; use std::{collections::HashSet, fmt::Display}; use strum_macros::EnumString; @@ -81,8 +82,10 @@ impl From<(ActivityGuid, Activity)> for ActivityItem { PartialOrd, Ord, EnumString, + strum::Display, )] #[serde(rename_all = "kebab-case")] +#[strum(serialize_all = "kebab-case")] // #[serde(untagged)] pub enum ActivityKind { /// A generic activity @@ -270,6 +273,10 @@ impl ActivityEndOptions { pub const fn new(end: PaceDateTime, duration: PaceDuration) -> Self { Self { end, duration } } + + pub fn as_tuple(&self) -> (PaceDateTime, PaceDuration) { + (self.end, self.duration) + } } #[derive( @@ -307,6 +314,18 @@ impl ActivityKindOptions { #[derive(Debug, Clone, Serialize, Deserialize, Ord, PartialEq, PartialOrd, Eq, Copy, Hash)] pub struct ActivityGuid(Ulid); +impl ActivityGuid { + #[must_use] + pub fn new() -> Self { + Self(Ulid::new()) + } + + #[must_use] + pub fn with_id(id: Ulid) -> Self { + Self(id) + } +} + impl Display for ActivityGuid { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { self.0.fmt(f) diff --git a/crates/core/src/domain/status.rs b/crates/core/src/domain/status.rs index 8e4383b9..4675a8f5 100644 --- a/crates/core/src/domain/status.rs +++ b/crates/core/src/domain/status.rs @@ -1,4 +1,5 @@ use serde_derive::{Deserialize, Serialize}; +use strum::EnumString; #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[serde(rename_all = "kebab-case")] @@ -16,8 +17,22 @@ pub enum TaskStatus { Waiting, } -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[derive( + Debug, + Clone, + Copy, + Default, + Serialize, + Deserialize, + PartialEq, + Eq, + PartialOrd, + Ord, + EnumString, + strum::Display, +)] #[serde(rename_all = "kebab-case")] +#[strum(serialize_all = "kebab-case")] pub enum ActivityStatusKind { /// The initial state of an activity once it's created in the system but not yet started. #[default] diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs index 70df4e6c..915b6e43 100644 --- a/crates/core/src/error.rs +++ b/crates/core/src/error.rs @@ -6,7 +6,10 @@ use pace_time::error::PaceTimeErrorKind; use std::{error::Error, io, path::PathBuf}; use thiserror::Error; -use crate::domain::activity::{Activity, ActivityGuid}; +use crate::{ + config::DatabaseEngineKind, + domain::activity::{Activity, ActivityGuid}, +}; /// Result type that is being returned from test functions and methods that can fail and thus have errors. pub type TestResult = Result>; @@ -163,10 +166,17 @@ pub enum PaceErrorKind { #[error(transparent)] Template(#[from] TemplatingErrorKind), - /// SQLite database error: {0} + /// SQLite error: {0} #[error(transparent)] - #[cfg(feature = "sqlite")] - SQLite(#[from] SqliteDatabaseStoreErrorKind), + #[cfg(feature = "rusqlite")] + SQLite(#[from] rusqlite::Error), + + /// Database error: {0} + #[error(transparent)] + Database(#[from] DatabaseErrorKind), + + /// Database storage not configured + DatabaseStorageNotConfigured, } /// [`ActivityLogErrorKind`] describes the errors that can happen while dealing with the activity log. @@ -306,11 +316,25 @@ pub enum ActivityStoreErrorKind { /// [`SqliteDatabaseStoreErrorKind`] describes the errors that can happen while dealing with the SQLite database. #[non_exhaustive] -#[cfg(feature = "sqlite")] #[derive(Error, Debug, Display)] -pub enum SqliteDatabaseStoreErrorKind { - /// Error connecting to database: {0} - ConnectionFailed(String), +pub enum DatabaseErrorKind { + /// Error connecting to database: {0} - {1} + ConnectionFailed(String, String), + + /// No connection string provided + NoConnectionString, + + /// No configuration settings provided in configuration file, please set them up with `pace setup config` + NoConfigSettings, + + /// This database engine is currently not supported: {0} + UnsupportedDatabaseEngine(DatabaseEngineKind), + + /// Activity with id {0} not found + ActivityNotFound(ActivityGuid), + + /// Failed to create activity: {0} + ActivityCreationFailed(Activity), } trait PaceErrorMarker: Error {} @@ -321,11 +345,12 @@ impl PaceErrorMarker for toml::ser::Error {} impl PaceErrorMarker for serde_json::Error {} impl PaceErrorMarker for chrono::ParseError {} impl PaceErrorMarker for chrono::OutOfRangeError {} +#[cfg(feature = "rusqlite")] +impl PaceErrorMarker for rusqlite::Error {} impl PaceErrorMarker for ActivityLogErrorKind {} impl PaceErrorMarker for PaceTimeErrorKind {} impl PaceErrorMarker for ActivityStoreErrorKind {} -#[cfg(feature = "sqlite")] -impl PaceErrorMarker for SqliteDatabaseStoreErrorKind {} +impl PaceErrorMarker for DatabaseErrorKind {} impl PaceErrorMarker for TemplatingErrorKind {} impl From for PaceError diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index 7f0a3432..e85cfbb1 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -10,15 +10,18 @@ use crate::{ hold::HoldOptions, resume::ResumeOptions, DeleteOptions, EndOptions, KeywordOptions, UpdateOptions, }, - config::{ActivityLogStorageKind, PaceConfig}, + config::{ActivityLogStorageKind, DatabaseEngineKind, PaceConfig}, domain::{ activity::{Activity, ActivityGuid, ActivityItem, ActivityKind}, filter::{ActivityFilterKind, FilteredActivities}, status::ActivityStatusKind, }, - error::{PaceErrorKind, PaceOptResult, PaceResult}, + error::{DatabaseErrorKind, PaceErrorKind, PaceOptResult, PaceResult}, service::activity_store::ActivityStore, - storage::{file::TomlActivityStorage, in_memory::InMemoryActivityStorage}, + storage::{ + file::TomlActivityStorage, in_memory::InMemoryActivityStorage, + sqlite::SqliteActivityStorage, + }, }; /// A type of storage that can be synced to a persistent medium - a file @@ -27,9 +30,10 @@ pub mod file; /// An in-memory storage backend for activities. pub mod in_memory; -#[cfg(feature = "sqlite")] +#[cfg(feature = "rusqlite")] pub mod sqlite; + /// Get the storage backend from the configuration. /// /// # Arguments @@ -49,7 +53,35 @@ pub fn get_storage_from_config(config: &PaceConfig) -> PaceResult { - return Err(PaceErrorKind::DatabaseStorageNotImplemented.into()) + if config.database().is_some() { + let Some(db_config) = config.database() else { + return Err(DatabaseErrorKind::NoConfigSettings.into()); + }; + + match db_config.engine() { + DatabaseEngineKind::Sqlite => { + #[cfg(feature = "rusqlite")] + { + let connection_string = config + .database() + .as_ref() + .ok_or(DatabaseErrorKind::NoConnectionString)? + .connection_string(); + + debug!("Connecting to database: {}", &connection_string); + + SqliteActivityStorage::new(connection_string.clone())?.into() + } + #[cfg(not(feature = "rusqlite"))] + return Err(PaceErrorKind::DatabaseStorageNotImplemented.into()); + } + engine => { + return Err(DatabaseErrorKind::UnsupportedDatabaseEngine(*engine).into()) + } + } + } + + return Err(PaceErrorKind::DatabaseStorageNotConfigured.into()); } #[cfg(test)] ActivityLogStorageKind::InMemory => InMemoryActivityStorage::new().into(), @@ -65,6 +97,7 @@ pub enum StorageKind { ActivityStore, InMemoryActivityStorage, TomlActivityStorage, + SqliteActivityStorage, } impl Display for StorageKind { @@ -75,6 +108,7 @@ impl Display for StorageKind { write!(f, "StorageKind: InMemoryActivityStorage") } Self::TomlActivityStorage(_) => write!(f, "StorageKind: TomlActivityStorage"), + Self::SqliteActivityStorage(_) => write!(f, "StorageKind: SqliteActivityStorage"), } } } diff --git a/crates/core/src/storage/sqlite.rs b/crates/core/src/storage/sqlite.rs index c1e1b9e5..c21e15d3 100644 --- a/crates/core/src/storage/sqlite.rs +++ b/crates/core/src/storage/sqlite.rs @@ -1,22 +1,41 @@ +use std::collections::BTreeMap; + +use itertools::Itertools; +use rusqlite::Connection; + +use pace_time::{date::PaceDate, duration::PaceDurationRange, time_range::TimeRangeOptions}; +use tracing::debug; + use crate::{ domain::activity::Activity, - error::{PaceResult, SqliteDatabaseStoreErrorKind}, - storage::ActivityStorage, + error::{DatabaseErrorKind, PaceResult}, + prelude::{ + ActivityFilterKind, ActivityGuid, ActivityItem, ActivityKind, ActivityStatusKind, + DeleteOptions, EndOptions, FilteredActivities, HoldOptions, KeywordOptions, PaceOptResult, + ResumeOptions, UpdateOptions, + }, + storage::{ + ActivityQuerying, ActivityReadOps, ActivityStateManagement, ActivityStorage, + ActivityWriteOps, SyncStorage, + }, }; -use rusqlite::{Connection, Result}; -use serde::{Deserialize, Serialize}; -use std::env; +pub trait FromRow { + fn from_row(row: &rusqlite::Row<'_>) -> PaceResult + where + Self: Sized; +} -struct SqliteActivityStorage { +#[derive(Debug)] +pub struct SqliteActivityStorage { connection: Connection, } impl SqliteActivityStorage { pub fn new(connection_string: String) -> PaceResult { - let connection = Connection::open(connection_string.as_str()).map_err( - SqliteDatabaseStoreErrorKind::ConnectionFailed(connection_string), - )?; + let connection = Connection::open(connection_string.as_str()).map_err(|err| { + DatabaseErrorKind::ConnectionFailed(connection_string, err.to_string()) + })?; Ok(Self { connection }) } @@ -26,10 +45,412 @@ impl ActivityStorage for SqliteActivityStorage { fn setup_storage(&self) -> PaceResult<()> { // TODO!: Check if the needed tables are existing or if we // are dealing with a fresh database, so we need to create - // the tables + // the tables. + + Ok(()) + } +} - // +impl SyncStorage for SqliteActivityStorage { + fn sync(&self) -> PaceResult<()> { + // TODO!: Sync the activities to the database Ok(()) } } + +impl ActivityReadOps for SqliteActivityStorage { + #[tracing::instrument] + fn read_activity(&self, activity_id: ActivityGuid) -> PaceResult { + let mut stmt = self + .connection + .prepare("SELECT * FROM activities WHERE id = ?1")?; + + let activity_item_iter = + stmt.query_map(&[&activity_id], |row| Ok(ActivityItem::from_row(&row)))?; + + let activity_item = activity_item_iter + .filter_map_ok(|item| item.ok()) + .next() + .transpose()? + .ok_or(DatabaseErrorKind::ActivityNotFound(activity_id))?; + + debug!("Read activity: {:?}", activity_item); + + Ok(activity_item) + } + + #[tracing::instrument] + fn list_activities(&self, filter: ActivityFilterKind) -> PaceOptResult { + let mut stmt = self.connection.prepare(filter.to_sql_statement())?; + + let activity_item_iter = stmt.query_map([], |row| Ok(ActivityGuid::from_row(&row)))?; + + let activities = activity_item_iter + .filter_map_ok(|item| item.ok()) + .collect::, _>>()?; + + debug!("Listed activities: {:?}", activities); + + if activities.is_empty() { + return Ok(None); + } + + let activities = match filter { + ActivityFilterKind::Everything => FilteredActivities::Everything(activities), + ActivityFilterKind::OnlyActivities => FilteredActivities::OnlyActivities(activities), + ActivityFilterKind::Active => FilteredActivities::Active(activities), + ActivityFilterKind::ActiveIntermission => { + FilteredActivities::ActiveIntermission(activities) + } + ActivityFilterKind::Archived => FilteredActivities::Archived(activities), + ActivityFilterKind::Ended => FilteredActivities::Ended(activities), + ActivityFilterKind::Held => FilteredActivities::Held(activities), + ActivityFilterKind::Intermission => FilteredActivities::Intermission(activities), + ActivityFilterKind::TimeRange(_) => FilteredActivities::TimeRange(activities), + }; + + Ok(Some(activities)) + } +} + +impl ActivityWriteOps for SqliteActivityStorage { + fn create_activity(&self, activity: Activity) -> PaceResult { + let mut stmt = self + .connection + .prepare(activity.to_sql_prepare_statement())?; + + let (guid, params) = activity.to_sql_execute_statement()?; + + if stmt.execute(params.as_slice())? > 0 { + return Ok(ActivityItem::from((guid, activity))); + } + + return Err(DatabaseErrorKind::ActivityCreationFailed(activity).into()); + } + + fn update_activity( + &self, + activity_id: ActivityGuid, + updated_activity: Activity, + update_opts: UpdateOptions, + ) -> PaceResult { + todo!() + } + + fn delete_activity( + &self, + activity_id: ActivityGuid, + delete_opts: DeleteOptions, + ) -> PaceResult { + todo!() + } +} +impl ActivityStateManagement for SqliteActivityStorage { + fn hold_activity( + &self, + activity_id: ActivityGuid, + hold_opts: HoldOptions, + ) -> PaceResult { + todo!() + } + + fn resume_activity( + &self, + activity_id: ActivityGuid, + resume_opts: ResumeOptions, + ) -> PaceResult { + todo!() + } + + fn resume_most_recent_activity( + &self, + resume_opts: ResumeOptions, + ) -> PaceOptResult { + todo!() + } + + fn end_activity( + &self, + activity_id: ActivityGuid, + end_opts: EndOptions, + ) -> PaceResult { + todo!() + } + + fn end_all_activities(&self, end_opts: EndOptions) -> PaceOptResult> { + todo!() + } + + fn end_all_active_intermissions( + &self, + end_opts: EndOptions, + ) -> PaceOptResult> { + todo!() + } + + fn end_last_unfinished_activity(&self, end_opts: EndOptions) -> PaceOptResult { + todo!() + } + + fn hold_most_recent_active_activity( + &self, + hold_opts: HoldOptions, + ) -> PaceOptResult { + todo!() + } +} +impl ActivityQuerying for SqliteActivityStorage { + fn group_activities_by_duration_range( + &self, + ) -> PaceOptResult>> { + todo!() + } + + fn group_activities_by_start_date( + &self, + ) -> PaceOptResult>> { + todo!() + } + + fn list_activities_with_intermissions( + &self, + ) -> PaceOptResult>> { + todo!() + } + + fn group_activities_by_keywords( + &self, + keyword_opts: KeywordOptions, + ) -> PaceOptResult>> { + todo!() + } + + fn group_activities_by_kind(&self) -> PaceOptResult>> { + todo!() + } + + fn list_activities_by_time_range( + &self, + time_range_opts: TimeRangeOptions, + ) -> PaceOptResult> { + todo!() + } + + fn group_activities_by_status( + &self, + ) -> PaceOptResult>> { + todo!() + } + + fn list_activities_by_id(&self) -> PaceOptResult> { + todo!() + } +} + +pub mod sql_conversion { + use std::{collections::HashSet, str::FromStr}; + + use pace_time::date_time::PaceDateTime; + use rusqlite::{types::FromSql, Row, ToSql}; + use ulid::Ulid; + + // use pace_time::rusqlite::*; + + use crate::{ + prelude::{ + Activity, ActivityEndOptions, ActivityFilterKind, ActivityGuid, ActivityItem, + ActivityKind, ActivityKindOptions, ActivityStatusKind, PaceResult, + }, + storage::sqlite::FromRow, + }; + + impl Activity { + pub fn to_sql_prepare_statement(&self) -> &'static str { + "INSERT INTO activities (id, category, description, begin, end, duration, kind, status, tags, parent_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)" + } + + pub fn to_sql_execute_statement(&self) -> PaceResult<(ActivityGuid, Vec<&dyn ToSql>)> { + let category = if let Some(category) = self.category() { + category.to_sql()? + } else { + "NULL".to_sql()? + }; + + let (end, duration) = if let Some(end_opts) = self.activity_end_options().as_ref() { + let (end, duration) = end_opts.as_tuple(); + (end.to_sql()?, duration.to_sql()?) + } else { + ("NULL".to_sql()?, "NULL".to_sql()?) + }; + + let parent_id = if let Some(parent_id) = self.parent_id() { + parent_id.to_sql()? + } else { + "NULL".to_sql()? + }; + + let tags = if let Some(tags) = self.tags() { + let tags = tags + .iter() + .map(|tag| tag.to_string()) + .collect::>(); + + tags.join(",").to_sql()? + } else { + "NULL".to_sql()? + }; + + let guid = ActivityGuid::new(); + + Ok(( + guid, + vec![ + // TODO: We create a new ID here, that should probably happen + // TODO: somewhere else and needs a refactoring + &guid, + &category, + &self.description(), + &self.begin(), + &end, + &duration, + &self.kind(), + &self.status(), + &tags, + &parent_id, + ], + )) + } + } + + impl ActivityFilterKind { + pub fn to_sql_statement(&self) -> &'static str { + match self { + Self::Everything => "SELECT * FROM activities", + ActivityFilterKind::OnlyActivities => todo!(), + ActivityFilterKind::Active => { + "SELECT * FROM activities WHERE status = 'in-progress'" + } + ActivityFilterKind::ActiveIntermission => todo!(), + ActivityFilterKind::Archived => { + "SELECT * FROM activities WHERE status = 'archived'" + } + ActivityFilterKind::Ended => "SELECT * FROM activities WHERE status = 'completed'", + ActivityFilterKind::Held => "SELECT * FROM activities WHERE status = 'paused'", + ActivityFilterKind::Intermission => todo!(), + ActivityFilterKind::TimeRange(opts) => todo!(), + } + } + } + + impl FromRow for ActivityEndOptions { + fn from_row(row: &Row<'_>) -> PaceResult { + Ok(Self::new(row.get("end")?, row.get("duration")?)) + } + } + + impl FromRow for ActivityKindOptions { + fn from_row(row: &Row<'_>) -> PaceResult { + Ok(Self::with_parent_id(row.get("parent_id")?)) + } + } + + impl FromRow for Activity { + fn from_row(row: &Row<'_>) -> PaceResult { + let begin_time: PaceDateTime = row.get("begin")?; + + let description: String = row.get("description")?; + + let tags_string: String = row.get("tags")?; + + let tags = tags_string + .split(',') + .map(|tag| tag.to_string()) + .collect::>(); + + Ok(Activity::builder() + .category(Some(row.get("category")?)) // TODO: Check for None + .description(description) + .begin(begin_time) + .activity_end_options(Some(ActivityEndOptions::from_row(row)?)) // TODO: Check for None + .kind(row.get("kind")?) + .activity_kind_options(Some(ActivityKindOptions::from_row(row)?)) // TODO: Check for None + .tags(tags) + .status(row.get("status")?) + .build()) + } + } + + impl FromRow for ActivityGuid { + fn from_row(row: &Row<'_>) -> PaceResult { + Ok(row.get("guid")?) + } + } + + impl FromRow for ActivityItem { + fn from_row(row: &Row<'_>) -> PaceResult { + let activity_end_opts = ActivityEndOptions::from_row(row)?; + + let activity_kind_opts = ActivityKindOptions::from_row(row)?; + + let activity = Activity::from_row(row)?; + + let guid = ActivityGuid::from_row(row)?; + + Ok(Self::builder().guid(guid).activity(activity).build()) + } + } + + impl ToSql for ActivityGuid { + fn to_sql(&self) -> rusqlite::Result> { + Ok(rusqlite::types::ToSqlOutput::Owned( + rusqlite::types::Value::Text(self.to_string()), + )) + } + } + + impl FromSql for ActivityGuid { + fn column_result( + value: rusqlite::types::ValueRef<'_>, + ) -> rusqlite::types::FromSqlResult { + Ok(ActivityGuid::with_id( + Ulid::from_string(value.as_str()?) + .map_err(|err| rusqlite::types::FromSqlError::Other(Box::new(err)))?, + )) + } + } + + impl ToSql for ActivityKind { + fn to_sql(&self) -> rusqlite::Result> { + Ok(rusqlite::types::ToSqlOutput::Owned( + rusqlite::types::Value::Text(self.to_string()), + )) + } + } + + impl FromSql for ActivityKind { + fn column_result( + value: rusqlite::types::ValueRef<'_>, + ) -> rusqlite::types::FromSqlResult { + Ok(ActivityKind::from_str(value.as_str()?) + .map_err(|err| rusqlite::types::FromSqlError::Other(Box::new(err)))?) + } + } + + impl ToSql for ActivityStatusKind { + fn to_sql(&self) -> rusqlite::Result> { + Ok(rusqlite::types::ToSqlOutput::Owned( + rusqlite::types::Value::Text(self.to_string()), + )) + } + } + + impl FromSql for ActivityStatusKind { + fn column_result( + value: rusqlite::types::ValueRef<'_>, + ) -> rusqlite::types::FromSqlResult { + Ok(ActivityStatusKind::from_str(value.as_str()?) + .map_err(|err| rusqlite::types::FromSqlError::Other(Box::new(err)))?) + } + } +} diff --git a/crates/time/Cargo.toml b/crates/time/Cargo.toml index d8fa4146..a3d13be6 100644 --- a/crates/time/Cargo.toml +++ b/crates/time/Cargo.toml @@ -12,9 +12,11 @@ rust-version = { workspace = true } description = "pace-time - a library for handling date times, ranges, and durations for pace" [features] -default = ["cli"] +default = ["cli", "db"] cli = ["clap"] +db = ["rusqlite"] clap = ["dep:clap"] +rusqlite = ["dep:rusqlite"] [dependencies] chrono = { workspace = true, features = ["serde"] } @@ -24,6 +26,7 @@ derive_more = { workspace = true, features = ["add", "add_assign"] } displaydoc = { workspace = true } getset = { workspace = true } humantime = { workspace = true } +rusqlite = { workspace = true, optional = true, features = ["bundled", "chrono"] } serde = { workspace = true } serde_derive = { workspace = true } thiserror = { workspace = true } diff --git a/crates/time/README.md b/crates/time/README.md index 99bd5652..ea72193a 100644 --- a/crates/time/README.md +++ b/crates/time/README.md @@ -42,6 +42,12 @@ This crate exposes a few features for controlling dependency usage: - **cli** - Enables support for CLI features by enabling `merge` and `clap` features. *This feature is enabled by default*. +- **db** - Enables support for database features by enabling `rusqlite` features. + *This feature is enabled by default*. + +- **rusqlite** - Enables a dependency on the `rusqlite` crate and enables + database support. *This feature is enabled by default*. + ## Examples TODO! diff --git a/crates/time/src/lib.rs b/crates/time/src/lib.rs index 50b84ab1..230c1dee 100644 --- a/crates/time/src/lib.rs +++ b/crates/time/src/lib.rs @@ -3,6 +3,8 @@ pub mod date_time; pub mod duration; pub mod error; pub mod flags; +#[cfg(feature = "rusqlite")] +pub mod rusqlite; pub mod time; pub mod time_frame; pub mod time_range; diff --git a/crates/time/src/rusqlite.rs b/crates/time/src/rusqlite.rs new file mode 100644 index 00000000..d30eee0f --- /dev/null +++ b/crates/time/src/rusqlite.rs @@ -0,0 +1,39 @@ +use rusqlite::{types::FromSql, ToSql}; + +use crate::{date_time::PaceDateTime, duration::PaceDuration}; + +impl ToSql for PaceDateTime { + fn to_sql(&self) -> rusqlite::Result> { + Ok(rusqlite::types::ToSqlOutput::Owned( + rusqlite::types::Value::Text(self.to_string()), + )) + } +} + +impl FromSql for PaceDateTime { + fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult { + value + .as_str()? + .parse::() + .map_err(|err| rusqlite::types::FromSqlError::Other(Box::new(err))) + } +} + +impl ToSql for PaceDuration { + fn to_sql(&self) -> rusqlite::Result> { + Ok(rusqlite::types::ToSqlOutput::Owned( + rusqlite::types::Value::Integer( + i64::try_from(self.inner()) + .map_err(|err| rusqlite::Error::ToSqlConversionFailure(Box::new(err)))?, + ), + )) + } +} + +impl FromSql for PaceDuration { + fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult { + Ok(Self::new(u64::try_from(value.as_i64()?).map_err( + |err| rusqlite::types::FromSqlError::Other(Box::new(err)), + )?)) + } +} diff --git a/db/migrations/20240325143710_create_activities.sql b/db/migrations/20240325143710_create_activities.sql index 9bebb38b..f08d2503 100644 --- a/db/migrations/20240325143710_create_activities.sql +++ b/db/migrations/20240325143710_create_activities.sql @@ -1,6 +1,6 @@ -- migrate:up CREATE TABLE activities ( - id TEXT PRIMARY KEY, + guid TEXT PRIMARY KEY, category TEXT NOT NULL, description TEXT NOT NULL, begin TEXT NOT NULL, @@ -8,8 +8,9 @@ CREATE TABLE activities ( duration INTEGER NULL, kind TEXT NOT NULL, status TEXT NOT NULL, - parent_id TEXT NULL, - FOREIGN KEY (parent_id) REFERENCES activities(id) + tags TEXT NULL, + parent_guid TEXT NULL, + FOREIGN KEY (parent_id) REFERENCES activities(guid) ); -- migrate:down