feat: implement cache service with repository and DTO layers

This commit is contained in:
GW_MC
2026-05-28 13:13:58 +00:00
parent aa6496de59
commit bda56b7e7e
5 changed files with 422 additions and 1 deletions

108
src-tauri/src/services/cache/dto.rs vendored Normal file
View File

@@ -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<Option<CacheModel>> for CacheValue {
fn from(opt: Option<CacheModel>) -> Self {
match opt {
Some(model) => model.into(),
None => CacheValue::NotFound,
}
}
}
impl From<CacheModel> for (String, CacheValue) {
fn from(model: CacheModel) -> Self {
let key = model.key.clone();
let value = model.into();
(key, value)
}
}
impl From<CacheModel> 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<DbErr> 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)
}
}

6
src-tauri/src/services/cache/mod.rs vendored Normal file
View File

@@ -0,0 +1,6 @@
mod dto;
mod repo;
mod service;
pub use dto::*;
pub use service::*;

176
src-tauri/src/services/cache/repo.rs vendored Normal file
View File

@@ -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<T> = CacheResult<T>;
#[async_trait::async_trait]
pub trait CacheRepo: Send + Sync + 'static {
async fn get_category(&self, category: CacheCategory) -> Result<HashMap<String, CacheModel>>;
async fn get(&self, category: CacheCategory, key: &str) -> Result<Option<CacheModel>>;
async fn set_with_tx(
&self,
tx: &Db,
category: CacheCategory,
key: &str,
value: &str,
expire_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<()>;
async fn set(
&self,
category: CacheCategory,
key: &str,
value: &str,
expire_at: Option<chrono::DateTime<chrono::Utc>>,
) -> 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<chrono::DateTime<chrono::Utc>>,
) -> Result<()>;
}
pub struct CacheRepoImpl {
pub db: DatabaseConnection,
}
#[async_trait::async_trait]
impl CacheRepo for CacheRepoImpl {
async fn get_category(&self, category: CacheCategory) -> Result<HashMap<String, CacheModel>> {
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<Option<CacheModel>> {
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<chrono::DateTime<chrono::Utc>>,
) -> 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<chrono::DateTime<chrono::Utc>>,
) -> 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<chrono::DateTime<chrono::Utc>>,
) -> 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(())
}
}

123
src-tauri/src/services/cache/service.rs vendored Normal file
View File

@@ -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<T> = std::result::Result<T, CacheServiceError>;
type Result<T> = CacheResult<T>;
#[async_trait::async_trait]
pub trait CacheService: Send + Sync + 'static {
async fn get_category(&self, category: CacheCategory) -> Result<HashMap<String, CacheValue>>;
async fn get(&self, category: CacheCategory, key: &str) -> Result<CacheValue>;
async fn set_with_tx(
&self,
tx: &Db,
category: CacheCategory,
key: &str,
value: &str,
expire_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<()>;
async fn set(
&self,
category: CacheCategory,
key: &str,
value: &str,
expire_at: Option<chrono::DateTime<chrono::Utc>>,
) -> 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<chrono::DateTime<chrono::Utc>>,
) -> Result<()>;
}
pub struct CacheServiceImpl<T>
where
T: super::repo::CacheRepo + ?Sized,
{
db: DatabaseConnection,
repo: Arc<T>,
}
impl CacheServiceImpl<super::repo::CacheRepoImpl> {
pub fn new(db: DatabaseConnection) -> Self {
Self {
db: db.clone(),
repo: Arc::new(super::repo::CacheRepoImpl { db }),
}
}
}
#[async_trait::async_trait]
impl<T> CacheService for CacheServiceImpl<T>
where
T: super::repo::CacheRepo + ?Sized,
{
async fn get_category(&self, category: CacheCategory) -> Result<HashMap<String, CacheValue>> {
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<CacheValue> {
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<chrono::DateTime<chrono::Utc>>,
) -> 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<chrono::DateTime<chrono::Utc>>,
) -> 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<chrono::DateTime<chrono::Utc>>,
) -> Result<()> {
self.repo.set_expire(category, key, expire_at).await
}
}

View File

@@ -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<AC, A, T, TC, TT>
pub struct Services<AC, A, T, TC, TT, C>
where
AC: AccountCategoryService + ?Sized,
A: AccountsService + ?Sized,
T: TransactionService + ?Sized,
TC: CategoryService + ?Sized,
TT: TagService + ?Sized,
C: CacheService + ?Sized,
{
pub account_category: Arc<AC>,
pub accounts: Arc<A>,
pub transaction: Arc<T>,
pub transaction_category: Arc<TC>,
pub transaction_tag: Arc<TT>,
pub cache: Arc<C>,
}
pub fn create_services(db: DatabaseConnection) -> AppState {
@@ -51,6 +56,8 @@ pub fn create_services(db: DatabaseConnection) -> AppState {
let transaction_category_service: Arc<dyn CategoryService> =
Arc::new(CategoryServiceImpl::new(db.clone()));
let transaction_tag_service: Arc<dyn TagService> = Arc::new(TagServiceImpl::new(db.clone()));
let cache_service: Arc<dyn CacheService> = 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<dyn TransactionService>,
transaction_category: transaction_category_service,
transaction_tag: transaction_tag_service,
cache: cache_service,
}
}