feat: implement category service with CRUD operations and validation

This commit is contained in:
GW_MC
2026-05-28 04:01:44 +00:00
parent 26c308fcb6
commit 0d2e59a9eb
4 changed files with 410 additions and 0 deletions

View 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);
}
}
}

View File

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

View 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(())
}
}

View 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
}
}