feat: implement category service with CRUD operations and validation
This commit is contained in:
165
src-tauri/src/services/transaction/category/dto.rs
Normal file
165
src-tauri/src/services/transaction/category/dto.rs
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use sea_orm::ActiveValue::Set;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::entities::category::{ActiveModel as CategoryActiveModel, Model as CategoryModel},
|
||||||
|
services::transaction::category::{repo::CategoryRepoService, CategoryServiceError},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct Category {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub color_code: String,
|
||||||
|
pub parent_id: Option<i64>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CategoryModel> for Category {
|
||||||
|
fn from(model: CategoryModel) -> Self {
|
||||||
|
Self {
|
||||||
|
id: model.id,
|
||||||
|
name: model.name,
|
||||||
|
color_code: model.color_code,
|
||||||
|
parent_id: model.parent_id,
|
||||||
|
created_at: model.created_at.to_string(),
|
||||||
|
updated_at: model.updated_at.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CategoryWithParent {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub color_code: String,
|
||||||
|
pub parent_id: Option<i64>,
|
||||||
|
pub parent: Option<Box<Category>>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<(CategoryModel, Option<CategoryModel>)> for CategoryWithParent {
|
||||||
|
fn from((model, parent): (CategoryModel, Option<CategoryModel>)) -> Self {
|
||||||
|
Self {
|
||||||
|
id: model.id,
|
||||||
|
name: model.name,
|
||||||
|
color_code: model.color_code,
|
||||||
|
parent_id: model.parent_id,
|
||||||
|
parent: parent.map(|parent_model| Box::new(parent_model.into())),
|
||||||
|
created_at: model.created_at.to_string(),
|
||||||
|
updated_at: model.updated_at.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CreateCategoryRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub color_code: String,
|
||||||
|
pub parent_id: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CreateCategoryRequest {
|
||||||
|
pub async fn validate<T>(
|
||||||
|
&self,
|
||||||
|
category_repo: Arc<T>,
|
||||||
|
) -> std::result::Result<(), CategoryServiceError>
|
||||||
|
where
|
||||||
|
T: CategoryRepoService + ?Sized,
|
||||||
|
{
|
||||||
|
if self.name.trim().is_empty() {
|
||||||
|
return Err(CategoryServiceError::Validation(
|
||||||
|
"Name cannot be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.color_code.trim().is_empty() {
|
||||||
|
return Err(CategoryServiceError::Validation(
|
||||||
|
"Color code cannot be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(parent_id) = self.parent_id {
|
||||||
|
let res = category_repo.get_category(parent_id).await?;
|
||||||
|
if res.is_none() {
|
||||||
|
return Err(CategoryServiceError::Validation(format!(
|
||||||
|
"Parent category with id {} does not exist",
|
||||||
|
parent_id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CreateCategoryRequest> for CategoryActiveModel {
|
||||||
|
fn from(request: CreateCategoryRequest) -> Self {
|
||||||
|
Self {
|
||||||
|
name: Set(request.name),
|
||||||
|
color_code: Set(request.color_code),
|
||||||
|
parent_id: Set(request.parent_id),
|
||||||
|
created_at: Set(chrono::Utc::now().to_rfc3339()),
|
||||||
|
updated_at: Set(chrono::Utc::now().to_rfc3339()),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UpdateCategoryRequest {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub color_code: Option<String>,
|
||||||
|
pub parent_id: Option<Option<i64>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateCategoryRequest {
|
||||||
|
pub async fn validate<T>(
|
||||||
|
&self,
|
||||||
|
category_repo: Arc<T>,
|
||||||
|
) -> std::result::Result<(), CategoryServiceError>
|
||||||
|
where
|
||||||
|
T: CategoryRepoService + ?Sized,
|
||||||
|
{
|
||||||
|
if let Some(name) = &self.name {
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
return Err(CategoryServiceError::Validation(
|
||||||
|
"Name cannot be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(color_code) = &self.color_code {
|
||||||
|
if color_code.trim().is_empty() {
|
||||||
|
return Err(CategoryServiceError::Validation(
|
||||||
|
"Color code cannot be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(Some(parent_id)) = self.parent_id {
|
||||||
|
let res = category_repo.get_category(parent_id).await?;
|
||||||
|
if res.is_none() {
|
||||||
|
return Err(CategoryServiceError::Validation(format!(
|
||||||
|
"Parent category with id {} does not exist",
|
||||||
|
parent_id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_to(self, category: &mut CategoryActiveModel) {
|
||||||
|
if let Some(name) = self.name {
|
||||||
|
category.name = Set(name);
|
||||||
|
}
|
||||||
|
if let Some(color_code) = self.color_code {
|
||||||
|
category.color_code = Set(color_code);
|
||||||
|
}
|
||||||
|
if let Some(Some(parent_id)) = self.parent_id {
|
||||||
|
category.parent_id = Set(Some(parent_id));
|
||||||
|
} else if let Some(None) = self.parent_id {
|
||||||
|
category.parent_id = Set(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src-tauri/src/services/transaction/category/mod.rs
Normal file
6
src-tauri/src/services/transaction/category/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
mod dto;
|
||||||
|
pub(super) mod repo;
|
||||||
|
mod service;
|
||||||
|
|
||||||
|
pub use dto::*;
|
||||||
|
pub use service::*;
|
||||||
124
src-tauri/src/services/transaction/category/repo.rs
Normal file
124
src-tauri/src/services/transaction/category/repo.rs
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::{
|
||||||
|
entities::category::{ActiveModel as CategoryActiveModel, Entity as CategoryEntity},
|
||||||
|
Db,
|
||||||
|
},
|
||||||
|
services::transaction::category::{
|
||||||
|
Category, CategoryServiceError, CategoryServiceResult, CategoryWithParent,
|
||||||
|
CreateCategoryRequest, UpdateCategoryRequest,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, DatabaseConnection, EntityTrait, Linked, ModelTrait, RelationTrait,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub type Result<T> = CategoryServiceResult<T>;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait CategoryRepoService: Send + Sync + 'static {
|
||||||
|
async fn get_categories(&self) -> Result<Vec<Category>>;
|
||||||
|
async fn get_category(&self, id: i64) -> Result<Option<CategoryWithParent>>;
|
||||||
|
async fn create_category(&self, request: CreateCategoryRequest) -> Result<i64>;
|
||||||
|
async fn create_category_with_tx(&self, request: CreateCategoryRequest, tx: &Db)
|
||||||
|
-> Result<i64>;
|
||||||
|
async fn update_category(&self, id: i64, request: UpdateCategoryRequest) -> Result<()>;
|
||||||
|
async fn update_category_with_tx(
|
||||||
|
&self,
|
||||||
|
id: i64,
|
||||||
|
request: UpdateCategoryRequest,
|
||||||
|
tx: &Db,
|
||||||
|
) -> Result<()>;
|
||||||
|
async fn delete_category(&self, id: i64) -> Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct CategoryRepoServiceImpl {
|
||||||
|
pub db: DatabaseConnection,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CategorySelfRefLink;
|
||||||
|
|
||||||
|
impl Linked for CategorySelfRefLink {
|
||||||
|
type FromEntity = CategoryEntity;
|
||||||
|
type ToEntity = CategoryEntity;
|
||||||
|
|
||||||
|
fn link(&self) -> Vec<sea_orm::RelationDef> {
|
||||||
|
vec![
|
||||||
|
// Use the BelongsTo or HasMany relation you defined in your enum
|
||||||
|
crate::db::entities::category::Relation::SelfRef.def(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl CategoryRepoService for CategoryRepoServiceImpl {
|
||||||
|
async fn get_categories(&self) -> Result<Vec<Category>> {
|
||||||
|
let categories = CategoryEntity::find().all(&self.db).await?;
|
||||||
|
Ok(categories.into_iter().map(|model| model.into()).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_category(&self, id: i64) -> Result<Option<CategoryWithParent>> {
|
||||||
|
let category = CategoryEntity::find_by_id(id)
|
||||||
|
.find_also_linked(CategorySelfRefLink)
|
||||||
|
.one(&self.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(category.map(|res| res.into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_category(&self, request: CreateCategoryRequest) -> Result<i64> {
|
||||||
|
self.create_category_with_tx(request, &(&self.db).into())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_category_with_tx(
|
||||||
|
&self,
|
||||||
|
request: CreateCategoryRequest,
|
||||||
|
tx: &Db,
|
||||||
|
) -> Result<i64> {
|
||||||
|
request.validate(Arc::new(self.clone())).await?;
|
||||||
|
|
||||||
|
let new_category: CategoryActiveModel = request.into();
|
||||||
|
let res = new_category.insert(tx).await?;
|
||||||
|
Ok(res.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_category(&self, id: i64, request: UpdateCategoryRequest) -> Result<()> {
|
||||||
|
self.update_category_with_tx(id, request, &(&self.db).into())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_category_with_tx(
|
||||||
|
&self,
|
||||||
|
id: i64,
|
||||||
|
request: UpdateCategoryRequest,
|
||||||
|
tx: &Db,
|
||||||
|
) -> Result<()> {
|
||||||
|
let category = CategoryEntity::find_by_id(id)
|
||||||
|
.one(tx)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
CategoryServiceError::TargetNotFound(format!("Category with id {} not found", id))
|
||||||
|
})?;
|
||||||
|
let mut category: CategoryActiveModel = category.into();
|
||||||
|
request.validate(Arc::new(self.clone())).await?;
|
||||||
|
|
||||||
|
request.apply_to(&mut category);
|
||||||
|
|
||||||
|
category.update(tx).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_category(&self, id: i64) -> Result<()> {
|
||||||
|
let category = CategoryEntity::find_by_id(id)
|
||||||
|
.one(&self.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
CategoryServiceError::TargetNotFound(format!("Category with id {} not found", id))
|
||||||
|
})?;
|
||||||
|
category.delete(&self.db).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src-tauri/src/services/transaction/category/service.rs
Normal file
115
src-tauri/src/services/transaction/category/service.rs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use sea_orm::DatabaseConnection;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::{Db, DbServiceError},
|
||||||
|
services::transaction::category::{
|
||||||
|
repo::{CategoryRepoService, CategoryRepoServiceImpl},
|
||||||
|
Category, CategoryWithParent, CreateCategoryRequest, UpdateCategoryRequest,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum CategoryServiceError {
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
DbErr(#[from] DbServiceError),
|
||||||
|
#[error("Target not found: {0}")]
|
||||||
|
TargetNotFound(String),
|
||||||
|
#[error("Validation error: {0}")]
|
||||||
|
Validation(String),
|
||||||
|
#[error("unknown error: {0}")]
|
||||||
|
Unknown(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<sea_orm::DbErr> for CategoryServiceError {
|
||||||
|
fn from(err: sea_orm::DbErr) -> Self {
|
||||||
|
CategoryServiceError::DbErr(DbServiceError::from(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type CategoryServiceResult<T> = std::result::Result<T, CategoryServiceError>;
|
||||||
|
|
||||||
|
type Result<T> = CategoryServiceResult<T>;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait CategoryService: Send + Sync + 'static {
|
||||||
|
async fn get_categories(&self) -> Result<Vec<Category>>;
|
||||||
|
async fn get_category(&self, id: i64) -> Result<Option<CategoryWithParent>>;
|
||||||
|
async fn create_category(&self, request: CreateCategoryRequest) -> Result<i64>;
|
||||||
|
async fn create_category_with_tx(&self, request: CreateCategoryRequest, tx: &Db)
|
||||||
|
-> Result<i64>;
|
||||||
|
async fn update_category(&self, id: i64, request: UpdateCategoryRequest) -> Result<()>;
|
||||||
|
async fn update_category_with_tx(
|
||||||
|
&self,
|
||||||
|
id: i64,
|
||||||
|
request: UpdateCategoryRequest,
|
||||||
|
tx: &Db,
|
||||||
|
) -> Result<()>;
|
||||||
|
async fn delete_category(&self, id: i64) -> Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CategoryServiceImpl<T>
|
||||||
|
where
|
||||||
|
T: CategoryRepoService + ?Sized,
|
||||||
|
{
|
||||||
|
db: DatabaseConnection,
|
||||||
|
category_repo: Arc<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CategoryServiceImpl<CategoryRepoServiceImpl> {
|
||||||
|
pub fn new(db: DatabaseConnection) -> Self {
|
||||||
|
Self {
|
||||||
|
db: db.clone(),
|
||||||
|
category_repo: Arc::new(CategoryRepoServiceImpl { db }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl<T> CategoryService for CategoryServiceImpl<T>
|
||||||
|
where
|
||||||
|
T: CategoryRepoService + ?Sized,
|
||||||
|
{
|
||||||
|
async fn get_categories(&self) -> Result<Vec<Category>> {
|
||||||
|
self.category_repo.get_categories().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_category(&self, id: i64) -> Result<Option<CategoryWithParent>> {
|
||||||
|
self.category_repo.get_category(id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_category(&self, request: CreateCategoryRequest) -> Result<i64> {
|
||||||
|
self.category_repo.create_category(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_category_with_tx(
|
||||||
|
&self,
|
||||||
|
request: CreateCategoryRequest,
|
||||||
|
tx: &Db,
|
||||||
|
) -> Result<i64> {
|
||||||
|
self.category_repo
|
||||||
|
.create_category_with_tx(request, tx)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_category(&self, id: i64, request: UpdateCategoryRequest) -> Result<()> {
|
||||||
|
self.category_repo.update_category(id, request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_category_with_tx(
|
||||||
|
&self,
|
||||||
|
id: i64,
|
||||||
|
request: UpdateCategoryRequest,
|
||||||
|
tx: &Db,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.category_repo
|
||||||
|
.update_category_with_tx(id, request, tx)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_category(&self, id: i64) -> Result<()> {
|
||||||
|
self.category_repo.delete_category(id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user