From aa6496de59362f792ee0419971de7af27f58fdfe Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Thu, 28 May 2026 13:13:28 +0000 Subject: [PATCH 1/2] feat: add migration for cache table with necessary columns and indexes --- crates/migration/src/lib.rs | 2 + .../m20260528_110733_create_cache_table.rs | 61 +++++++++++++++++++ src-tauri/src/db/entities/cache.rs | 24 ++++++++ src-tauri/src/db/entities/mod.rs | 1 + src-tauri/src/db/entities/prelude.rs | 1 + 5 files changed, 89 insertions(+) create mode 100644 crates/migration/src/m20260528_110733_create_cache_table.rs create mode 100644 src-tauri/src/db/entities/cache.rs diff --git a/crates/migration/src/lib.rs b/crates/migration/src/lib.rs index dfbb7a0..a2db3bd 100644 --- a/crates/migration/src/lib.rs +++ b/crates/migration/src/lib.rs @@ -2,6 +2,7 @@ pub use sea_orm_migration::prelude::*; mod m20260526_062833_create_account_table; mod m20260526_110957_create_transcations_table; +mod m20260528_110733_create_cache_table; pub struct Migrator; @@ -11,6 +12,7 @@ impl MigratorTrait for Migrator { vec![ Box::new(m20260526_062833_create_account_table::Migration), Box::new(m20260526_110957_create_transcations_table::Migration), + Box::new(m20260528_110733_create_cache_table::Migration), ] } } diff --git a/crates/migration/src/m20260528_110733_create_cache_table.rs b/crates/migration/src/m20260528_110733_create_cache_table.rs new file mode 100644 index 0000000..b984a16 --- /dev/null +++ b/crates/migration/src/m20260528_110733_create_cache_table.rs @@ -0,0 +1,61 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table("cache") + .comment("2 level cache for general use") + .if_not_exists() + .col(string("category").not_null().comment("Cache category")) + .col(string("key").not_null().comment("Cache key")) + .col(string("value").not_null().comment("Cache value")) + .col( + boolean("is_invalidated") + .not_null() + .comment("Whether the cache is manually invalidated"), + ) + .col(string("expire_at").null().comment("Expiration time")) + .col(string("created_at").not_null().comment("Creation time")) + .col(string("updated_at").not_null().comment("Last update time")) + // + .primary_key(Index::create().col("category").col("key")) + // + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_cache_category") + .table("cache") + .col("category") + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_cache_expire_at") + .table("cache") + .col("expire_at") + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table("cache").to_owned()) + .await + } +} diff --git a/src-tauri/src/db/entities/cache.rs b/src-tauri/src/db/entities/cache.rs new file mode 100644 index 0000000..c550f62 --- /dev/null +++ b/src-tauri/src/db/entities/cache.rs @@ -0,0 +1,24 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "cache")] +#[allow(dead_code)] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub category: String, + #[sea_orm(primary_key, auto_increment = false)] + pub key: String, + pub value: String, + pub is_invalidated: bool, + pub expire_at: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/mod.rs b/src-tauri/src/db/entities/mod.rs index dd10269..1a6ff07 100644 --- a/src-tauri/src/db/entities/mod.rs +++ b/src-tauri/src/db/entities/mod.rs @@ -4,6 +4,7 @@ pub mod prelude; pub mod account; pub mod account_category; +pub mod cache; pub mod category; pub mod tag; pub mod transaction; diff --git a/src-tauri/src/db/entities/prelude.rs b/src-tauri/src/db/entities/prelude.rs index 9045208..bb5b112 100644 --- a/src-tauri/src/db/entities/prelude.rs +++ b/src-tauri/src/db/entities/prelude.rs @@ -4,6 +4,7 @@ pub use super::account::Entity as Account; pub use super::account_category::Entity as AccountCategory; +pub use super::cache::Entity as Cache; pub use super::category::Entity as Category; pub use super::tag::Entity as Tag; pub use super::transaction::Entity as Transaction; From bda56b7e7e54d19748ff5cc3dd88108459af991c Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Thu, 28 May 2026 13:13:58 +0000 Subject: [PATCH 2/2] feat: implement cache service with repository and DTO layers --- src-tauri/src/services/cache/dto.rs | 108 +++++++++++++++ src-tauri/src/services/cache/mod.rs | 6 + src-tauri/src/services/cache/repo.rs | 176 ++++++++++++++++++++++++ src-tauri/src/services/cache/service.rs | 123 +++++++++++++++++ src-tauri/src/services/mod.rs | 10 +- 5 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 src-tauri/src/services/cache/dto.rs create mode 100644 src-tauri/src/services/cache/mod.rs create mode 100644 src-tauri/src/services/cache/repo.rs create mode 100644 src-tauri/src/services/cache/service.rs diff --git a/src-tauri/src/services/cache/dto.rs b/src-tauri/src/services/cache/dto.rs new file mode 100644 index 0000000..6868ab3 --- /dev/null +++ b/src-tauri/src/services/cache/dto.rs @@ -0,0 +1,108 @@ +use std::fmt::Display; + +use crate::db::entities::cache::Model as CacheModel; + +use sea_orm::DbErr; +use thiserror::Error; + +use crate::db::DbServiceError; + +pub enum CacheValue { + Ok(String), + Expired(String), + NotFound, +} + +impl From> for CacheValue { + fn from(opt: Option) -> Self { + match opt { + Some(model) => model.into(), + None => CacheValue::NotFound, + } + } +} + +impl From for (String, CacheValue) { + fn from(model: CacheModel) -> Self { + let key = model.key.clone(); + let value = model.into(); + (key, value) + } +} + +impl From for CacheValue { + fn from(model: CacheModel) -> Self { + if let Some(expire_at) = model.expire_at { + let expire_at = chrono::DateTime::parse_from_rfc3339(&expire_at) + .map(|dt| dt.with_timezone(&chrono::Utc)); + + match expire_at { + Ok(expire_at) if expire_at < chrono::Utc::now() => { + return CacheValue::Expired(model.value); + } + Ok(_) => {} // Not expired + Err(_) => { + // warn!("Failed to parse expire_at: {}", model.expire_at); + // If parsing fails, treat it as expired to be safe + return CacheValue::Expired(model.value); + } + } + } + + CacheValue::Ok(model.value) + } +} + +#[derive(Error, Debug)] +pub enum CacheServiceError { + #[error("database error: {0}")] + DbErr(#[from] DbServiceError), + #[error("target expired: {0}")] + TargetExpired(String), + #[error("Target not found: {0}")] + TargetNotFound(String), + #[error("Validation error: {0}")] + Validation(String), + #[error("unknown error: {0}")] + Unknown(String), +} + +impl From for CacheServiceError { + fn from(value: DbErr) -> Self { + CacheServiceError::DbErr(DbServiceError::from(value)) + } +} + +#[derive(Debug, Clone)] +pub enum CacheCategory { + Transaction, + TransactionTag, + Account, + Unknown(String), +} + +impl From<&str> for CacheCategory { + fn from(value: &str) -> Self { + match value { + "Transaction" => CacheCategory::Transaction, + "TransactionTag" => CacheCategory::TransactionTag, + "Account" => CacheCategory::Account, + other => { + // warn!("Unknown cache category: {}", other); + CacheCategory::Unknown(other.to_string()) + } + } + } +} + +impl Display for CacheCategory { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + CacheCategory::Transaction => "Transaction", + CacheCategory::TransactionTag => "TransactionTag", + CacheCategory::Account => "Account", + CacheCategory::Unknown(other) => other.as_str(), + }; + write!(f, "{}", s) + } +} diff --git a/src-tauri/src/services/cache/mod.rs b/src-tauri/src/services/cache/mod.rs new file mode 100644 index 0000000..73dad09 --- /dev/null +++ b/src-tauri/src/services/cache/mod.rs @@ -0,0 +1,6 @@ +mod dto; +mod repo; +mod service; + +pub use dto::*; +pub use service::*; diff --git a/src-tauri/src/services/cache/repo.rs b/src-tauri/src/services/cache/repo.rs new file mode 100644 index 0000000..341f78f --- /dev/null +++ b/src-tauri/src/services/cache/repo.rs @@ -0,0 +1,176 @@ +use std::collections::HashMap; + +use migration::Expr; +use sea_orm::{ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Value}; + +use super::{dto::CacheCategory, service::CacheResult}; +use crate::db::{ + Db, + entities::cache::{ + ActiveModel as CacheActiveModel, Column as CacheColumn, Entity as CacheEntity, + Model as CacheModel, + }, +}; + +type Result = CacheResult; + +#[async_trait::async_trait] +pub trait CacheRepo: Send + Sync + 'static { + async fn get_category(&self, category: CacheCategory) -> Result>; + async fn get(&self, category: CacheCategory, key: &str) -> Result>; + async fn set_with_tx( + &self, + tx: &Db, + category: CacheCategory, + key: &str, + value: &str, + expire_at: Option>, + ) -> Result<()>; + async fn set( + &self, + category: CacheCategory, + key: &str, + value: &str, + expire_at: Option>, + ) -> Result<()>; + async fn invalidate_category(&self, category: CacheCategory) -> Result<()>; + async fn invalidate_with_tx(&self, tx: &Db, category: CacheCategory, key: &str) -> Result<()>; + async fn invalidate(&self, category: CacheCategory, key: &str) -> Result<()>; + async fn set_expire( + &self, + category: CacheCategory, + key: &str, + expire_at: Option>, + ) -> Result<()>; +} + +pub struct CacheRepoImpl { + pub db: DatabaseConnection, +} + +#[async_trait::async_trait] +impl CacheRepo for CacheRepoImpl { + async fn get_category(&self, category: CacheCategory) -> Result> { + let cache_entries = CacheEntity::find() + .filter(CacheColumn::Category.eq(category.to_string())) + .all(&self.db) + .await?; + + let result = cache_entries + .into_iter() + .map(|entry| (entry.key.clone(), entry)) + .collect(); + Ok(result) + } + + async fn get(&self, category: CacheCategory, key: &str) -> Result> { + let cache_entry = CacheEntity::find() + .filter(CacheColumn::Category.eq(category.to_string())) + .filter(CacheColumn::Key.eq(key.to_string())) + .one(&self.db) + .await?; + + Ok(cache_entry) + } + + async fn set( + &self, + category: CacheCategory, + key: &str, + value: &str, + expire_at: Option>, + ) -> Result<()> { + self.set_with_tx(&(&self.db).into(), category, key, value, expire_at) + .await + } + + async fn set_with_tx( + &self, + tx: &Db, + category: CacheCategory, + key: &str, + value: &str, + expire_at: Option>, + ) -> Result<()> { + let expire_at_str = expire_at.map(|dt| dt.to_rfc3339()); + let now_str = chrono::Utc::now().to_rfc3339(); + let active_model = CacheActiveModel { + category: Set(category.to_string()), + key: Set(key.to_string()), + value: Set(value.to_string()), + is_invalidated: Set(false), + expire_at: Set(expire_at_str), + created_at: Set(now_str.clone()), + updated_at: Set(now_str), + }; + + CacheEntity::insert(active_model) + .on_conflict( + sea_orm::sea_query::OnConflict::columns([CacheColumn::Category, CacheColumn::Key]) + .update_columns([ + CacheColumn::Value, + CacheColumn::IsInvalidated, + CacheColumn::ExpireAt, + CacheColumn::UpdatedAt, + ]) + .to_owned(), + ) + .exec(tx) + .await?; + Ok(()) + } + + async fn invalidate_category(&self, category: CacheCategory) -> Result<()> { + CacheEntity::update_many() + .col_expr( + CacheColumn::IsInvalidated, + Expr::value(Value::Bool(Some(true))), + ) + .filter(CacheColumn::Category.eq(category.to_string())) + .exec(&self.db) + .await?; + Ok(()) + } + + async fn invalidate(&self, category: CacheCategory, key: &str) -> Result<()> { + self.invalidate_with_tx(&(&self.db).into(), category, key) + .await + } + + async fn invalidate_with_tx(&self, tx: &Db, category: CacheCategory, key: &str) -> Result<()> { + let target_entry = CacheActiveModel { + // pks + category: Set(category.to_string()), + key: Set(key.to_string()), + // update + is_invalidated: Set(true), + ..Default::default() + }; + CacheEntity::update(target_entry) + .validate()? + .exec(tx) + .await?; + Ok(()) + } + async fn set_expire( + &self, + category: CacheCategory, + key: &str, + expire_at: Option>, + ) -> Result<()> { + let expire_at_str = expire_at.map(|dt| dt.to_rfc3339()); + let target_entry = CacheActiveModel { + // pks + category: Set(category.to_string()), + key: Set(key.to_string()), + // update + expire_at: Set(expire_at_str), + ..Default::default() + }; + CacheEntity::update(target_entry) + .validate()? + .exec(&self.db) + .await?; + Ok(()) + } +} diff --git a/src-tauri/src/services/cache/service.rs b/src-tauri/src/services/cache/service.rs new file mode 100644 index 0000000..8039a86 --- /dev/null +++ b/src-tauri/src/services/cache/service.rs @@ -0,0 +1,123 @@ +use std::{collections::HashMap, sync::Arc}; + +use sea_orm::DatabaseConnection; + +use super::dto::CacheCategory; +use crate::{ + db::Db, + services::cache::{CacheServiceError, CacheValue}, +}; + +pub type CacheResult = std::result::Result; +type Result = CacheResult; + +#[async_trait::async_trait] +pub trait CacheService: Send + Sync + 'static { + async fn get_category(&self, category: CacheCategory) -> Result>; + async fn get(&self, category: CacheCategory, key: &str) -> Result; + async fn set_with_tx( + &self, + tx: &Db, + category: CacheCategory, + key: &str, + value: &str, + expire_at: Option>, + ) -> Result<()>; + async fn set( + &self, + category: CacheCategory, + key: &str, + value: &str, + expire_at: Option>, + ) -> Result<()>; + async fn invalidate_category(&self, category: CacheCategory) -> Result<()>; + async fn invalidate_with_tx(&self, tx: &Db, category: CacheCategory, key: &str) -> Result<()>; + async fn invalidate(&self, category: CacheCategory, key: &str) -> Result<()>; + async fn set_expire( + &self, + category: CacheCategory, + key: &str, + expire_at: Option>, + ) -> Result<()>; +} + +pub struct CacheServiceImpl +where + T: super::repo::CacheRepo + ?Sized, +{ + db: DatabaseConnection, + repo: Arc, +} + +impl CacheServiceImpl { + pub fn new(db: DatabaseConnection) -> Self { + Self { + db: db.clone(), + repo: Arc::new(super::repo::CacheRepoImpl { db }), + } + } +} + +#[async_trait::async_trait] +impl CacheService for CacheServiceImpl +where + T: super::repo::CacheRepo + ?Sized, +{ + async fn get_category(&self, category: CacheCategory) -> Result> { + let cache_entries = self.repo.get_category(category).await?; + + let result = cache_entries + .into_iter() + .map(|(key, model)| (key, model.into())) + .collect(); + Ok(result) + } + + async fn get(&self, category: CacheCategory, key: &str) -> Result { + let cache_entry = self.repo.get(category, key).await?; + Ok(cache_entry.into()) + } + + async fn set( + &self, + category: CacheCategory, + key: &str, + value: &str, + expire_at: Option>, + ) -> Result<()> { + self.repo.set(category, key, value, expire_at).await + } + + async fn set_with_tx( + &self, + tx: &Db, + category: CacheCategory, + key: &str, + value: &str, + expire_at: Option>, + ) -> Result<()> { + self.repo + .set_with_tx(tx, category, key, value, expire_at) + .await + } + + async fn invalidate_category(&self, category: CacheCategory) -> Result<()> { + self.repo.invalidate_category(category).await + } + + async fn invalidate(&self, category: CacheCategory, key: &str) -> Result<()> { + self.repo.invalidate(category, key).await + } + + async fn invalidate_with_tx(&self, tx: &Db, category: CacheCategory, key: &str) -> Result<()> { + self.repo.invalidate_with_tx(tx, category, key).await + } + async fn set_expire( + &self, + category: CacheCategory, + key: &str, + expire_at: Option>, + ) -> Result<()> { + self.repo.set_expire(category, key, expire_at).await + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 32b7087..eba39d5 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -7,6 +7,7 @@ use crate::services::{ AccountsService, AccountsServiceImpl, category::{AccountCategoryService, AccountCategoryServiceImpl}, }, + cache::{CacheService, CacheServiceImpl}, transaction::{ TransactionService, TransactionServiceImpl, category::{CategoryService, CategoryServiceImpl}, @@ -15,6 +16,7 @@ use crate::services::{ }; pub mod accounts; +pub mod cache; pub mod transaction; pub type AppState = Services< @@ -23,22 +25,25 @@ pub type AppState = Services< dyn TransactionService, dyn CategoryService, dyn TagService, + dyn CacheService, >; #[derive(Clone)] -pub struct Services +pub struct Services where AC: AccountCategoryService + ?Sized, A: AccountsService + ?Sized, T: TransactionService + ?Sized, TC: CategoryService + ?Sized, TT: TagService + ?Sized, + C: CacheService + ?Sized, { pub account_category: Arc, pub accounts: Arc, pub transaction: Arc, pub transaction_category: Arc, pub transaction_tag: Arc, + pub cache: Arc, } pub fn create_services(db: DatabaseConnection) -> AppState { @@ -51,6 +56,8 @@ pub fn create_services(db: DatabaseConnection) -> AppState { let transaction_category_service: Arc = Arc::new(CategoryServiceImpl::new(db.clone())); let transaction_tag_service: Arc = Arc::new(TagServiceImpl::new(db.clone())); + let cache_service: Arc = Arc::new(CacheServiceImpl::new(db.clone())); + Services { account_category: account_category_service.clone(), accounts: account_service.clone(), @@ -62,5 +69,6 @@ pub fn create_services(db: DatabaseConnection) -> AppState { )) as Arc, transaction_category: transaction_category_service, transaction_tag: transaction_tag_service, + cache: cache_service, } }