From 6e0826c5ec5f997d4bc51a0768d07ab470b668e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Volf?= <66163112+matous-volf@users.noreply.github.com> Date: Sun, 8 Sep 2024 19:51:52 +0200 Subject: [PATCH] feat: create a model for subtasks --- .../down.sql | 4 + .../2024-09-08-083610_create_subtasks/up.sql | 15 +++ src/errors/mod.rs | 1 + src/errors/subtask_error.rs | 70 +++++++++++ src/models/mod.rs | 1 + src/models/subtask.rs | 67 ++++++++++ src/query/mod.rs | 8 +- src/query/subtasks.rs | 21 ++++ src/schema/mod.rs | 13 ++ src/server/mod.rs | 1 + src/server/subtasks.rs | 115 ++++++++++++++++++ 11 files changed, 314 insertions(+), 2 deletions(-) create mode 100644 migrations/2024-09-08-083610_create_subtasks/down.sql create mode 100644 migrations/2024-09-08-083610_create_subtasks/up.sql create mode 100644 src/errors/subtask_error.rs create mode 100644 src/models/subtask.rs create mode 100644 src/query/subtasks.rs create mode 100644 src/server/subtasks.rs diff --git a/migrations/2024-09-08-083610_create_subtasks/down.sql b/migrations/2024-09-08-083610_create_subtasks/down.sql new file mode 100644 index 0000000..ca17be0 --- /dev/null +++ b/migrations/2024-09-08-083610_create_subtasks/down.sql @@ -0,0 +1,4 @@ +-- This file should undo anything in `up.sql` + + +DROP TABLE IF EXISTS "subtasks"; diff --git a/migrations/2024-09-08-083610_create_subtasks/up.sql b/migrations/2024-09-08-083610_create_subtasks/up.sql new file mode 100644 index 0000000..b16d362 --- /dev/null +++ b/migrations/2024-09-08-083610_create_subtasks/up.sql @@ -0,0 +1,15 @@ +-- Your SQL goes here + + +CREATE TABLE "subtasks"( + "id" SERIAL NOT NULL PRIMARY KEY, + "task_id" INT4 NOT NULL, + "title" TEXT NOT NULL, + "is_completed" BOOL NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE +); + +SELECT diesel_manage_updated_at('subtasks'); + diff --git a/src/errors/mod.rs b/src/errors/mod.rs index 3eeb29b..1dfc030 100644 --- a/src/errors/mod.rs +++ b/src/errors/mod.rs @@ -2,3 +2,4 @@ pub(crate) mod error; pub(crate) mod error_vec; pub(crate) mod project_error; pub(crate) mod task_error; +pub(crate) mod subtask_error; diff --git a/src/errors/subtask_error.rs b/src/errors/subtask_error.rs new file mode 100644 index 0000000..3297208 --- /dev/null +++ b/src/errors/subtask_error.rs @@ -0,0 +1,70 @@ +use crate::errors::error::Error; +use crate::errors::error_vec::ErrorVec; +use serde::{Deserialize, Serialize}; +use std::fmt::Display; +use std::str::FromStr; +use validator::{ValidationErrors, ValidationErrorsKind}; + +#[derive(Serialize, Deserialize, Debug)] +pub enum SubtaskError { + TitleLengthInvalid, + TaskNotFound, + Error(Error), +} + +impl From for ErrorVec { + fn from(validation_errors: ValidationErrors) -> Self { + validation_errors.errors() + .iter() + .flat_map(|(&field, error_kind)| match field { + "title" => match error_kind { + ValidationErrorsKind::Field(validation_errors) => validation_errors + .iter() + .map(|validation_error| validation_error.code.as_ref()) + .map(|code| match code { + "title_length" => SubtaskError::TitleLengthInvalid, + _ => panic!("Unexpected validation error code: `{code}`."), + }) + .collect::>(), + _ => panic!("Unexpected validation error kind."), + }, + _ => panic!("Unexpected validation field name: `{field}`."), + }) + .collect::>() + .into() + } +} + +impl From for SubtaskError { + fn from(diesel_error: diesel::result::Error) -> Self { + match diesel_error { + diesel::result::Error::DatabaseError( + diesel::result::DatabaseErrorKind::ForeignKeyViolation, info + ) => { + match info.constraint_name() { + Some("subtasks_task_id_fkey") => Self::TaskNotFound, + _ => Self::Error(Error::ServerInternal) + } + } + _ => { + Self::Error(Error::ServerInternal) + } + } + } +} + +// Has to be implemented for Dioxus server functions. +impl Display for SubtaskError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} + +// Has to be implemented for Dioxus server functions. +impl FromStr for SubtaskError { + type Err = (); + + fn from_str(_: &str) -> Result { + Ok(Self::Error(Error::ServerInternal)) + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 9f449ce..940d15b 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,3 +1,4 @@ pub(crate) mod project; pub(crate) mod category; pub(crate) mod task; +pub(crate) mod subtask; diff --git a/src/models/subtask.rs b/src/models/subtask.rs new file mode 100644 index 0000000..fbf9974 --- /dev/null +++ b/src/models/subtask.rs @@ -0,0 +1,67 @@ +use chrono::NaiveDateTime; +use crate::schema::subtasks; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use validator::Validate; + +const TITLE_LENGTH_MIN: u64 = 1; +const TITLE_LENGTH_MAX: u64 = 255; + +#[derive(Queryable, Selectable, Serialize, Deserialize, PartialEq, Clone, Debug)] +#[diesel(table_name = subtasks)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct Subtask { + id: i32, + task_id: i32, + title: String, + is_completed: bool, + created_at: NaiveDateTime, + updated_at: NaiveDateTime, +} + +impl Subtask { + pub fn id(&self) -> i32 { + self.id + } + + pub fn task_id(&self) -> i32 { + self.task_id + } + + pub fn title(&self) -> &str { + &self.title + } + + pub fn is_completed(&self) -> bool { + self.is_completed + } + + pub fn created_at(&self) -> NaiveDateTime { + self.created_at + } + + pub fn updated_at(&self) -> NaiveDateTime { + self.updated_at + } +} + +#[derive(Insertable, Serialize, Deserialize, Validate, Clone, Debug)] +#[diesel(table_name = subtasks)] +pub struct NewSubtask { + pub task_id: i32, + #[validate(length(min = "TITLE_LENGTH_MIN", max = "TITLE_LENGTH_MAX", code = "title_length"))] + pub title: String, + pub is_completed: bool, +} + +impl NewSubtask { + pub fn new(task_id: i32, title: String, is_completed: bool) -> Self { + Self { task_id, title, is_completed } + } +} + +impl From for NewSubtask { + fn from(subtask: Subtask) -> Self { + Self::new(subtask.task_id, subtask.title, subtask.is_completed) + } +} diff --git a/src/query/mod.rs b/src/query/mod.rs index 7e1566a..792d45b 100644 --- a/src/query/mod.rs +++ b/src/query/mod.rs @@ -2,15 +2,18 @@ use crate::errors::error::Error; use crate::errors::error_vec::ErrorVec; use crate::models::category::Category; use crate::models::project::Project; +use crate::models::subtask::Subtask; use crate::models::task::Task; pub(crate) mod tasks; pub(crate) mod projects; +pub(crate) mod subtasks; #[derive(PartialEq, Debug)] pub(crate) enum QueryValue { - Tasks(Vec), Projects(Vec), + Tasks(Vec), + Subtasks(Vec), } #[derive(Debug)] @@ -20,7 +23,8 @@ pub(crate) enum QueryErrors { #[derive(PartialEq, Eq, Hash, Clone, Debug)] pub(crate) enum QueryKey { + Projects, Tasks, TasksInCategory(Category), - Projects, + SubtasksOfTaskId(i32), } diff --git a/src/query/subtasks.rs b/src/query/subtasks.rs new file mode 100644 index 0000000..6ee4f1a --- /dev/null +++ b/src/query/subtasks.rs @@ -0,0 +1,21 @@ +use crate::query::{QueryErrors, QueryKey, QueryValue}; +use crate::server::subtasks::get_subtasks_of_task; +use dioxus::prelude::ServerFnError; +use dioxus_query::prelude::{use_get_query, QueryResult, UseQuery}; + +pub(crate) fn use_subtasks_of_task_query(task_id: i32) + -> UseQuery { + use_get_query([QueryKey::SubtasksOfTaskId(task_id)], fetch_subtasks_of_task) +} + +async fn fetch_subtasks_of_task(keys: Vec) -> QueryResult { + if let Some(QueryKey::SubtasksOfTaskId(task_id)) = keys.first() { + match get_subtasks_of_task(*task_id).await { + Ok(subtasks) => Ok(QueryValue::Subtasks(subtasks)), + Err(ServerFnError::WrappedServerError(errors)) => Err(QueryErrors::Error(errors)), + Err(error) => panic!("Unexpected error: {:?}", error) + }.into() + } else { + panic!("Unexpected query keys: {:?}", keys); + } +} diff --git a/src/schema/mod.rs b/src/schema/mod.rs index 7078264..d50a5ec 100644 --- a/src/schema/mod.rs +++ b/src/schema/mod.rs @@ -9,6 +9,17 @@ diesel::table! { } } +diesel::table! { + subtasks (id) { + id -> Int4, + task_id -> Int4, + title -> Text, + is_completed -> Bool, + created_at -> Timestamp, + updated_at -> Timestamp, + } +} + diesel::table! { tasks (id) { id -> Int4, @@ -21,9 +32,11 @@ diesel::table! { } } +diesel::joinable!(subtasks -> tasks (task_id)); diesel::joinable!(tasks -> projects (project_id)); diesel::allow_tables_to_appear_in_same_query!( projects, + subtasks, tasks, ); diff --git a/src/server/mod.rs b/src/server/mod.rs index 86a456c..59b63ad 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,3 +1,4 @@ mod database_connection; pub(crate) mod projects; pub(crate) mod tasks; +pub(crate) mod subtasks; diff --git a/src/server/subtasks.rs b/src/server/subtasks.rs new file mode 100644 index 0000000..6108575 --- /dev/null +++ b/src/server/subtasks.rs @@ -0,0 +1,115 @@ +use crate::errors::error::Error; +use crate::errors::error_vec::ErrorVec; +use crate::errors::subtask_error::SubtaskError; +use crate::models::subtask::{NewSubtask, Subtask}; +use crate::server::database_connection::establish_database_connection; +use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl, SelectableHelper}; +use dioxus::prelude::*; +use validator::Validate; + +#[server] +pub(crate) async fn create_subtask(new_subtask: NewSubtask) + -> Result>> { + use crate::schema::subtasks; + + new_subtask.validate() + .map_err::, _>(|errors| errors.into())?; + + let mut connection = establish_database_connection() + .map_err::, _>( + |_| vec![SubtaskError::Error(Error::ServerInternal)].into() + )?; + + let created_subtask = diesel::insert_into(subtasks::table) + .values(&new_subtask) + .returning(Subtask::as_returning()) + .get_result(&mut connection) + .map_err::, _>(|error| vec![error.into()].into())?; + + Ok(created_subtask) +} + +#[server] +pub(crate) async fn get_subtasks_of_task(filtered_task_id: i32) + -> Result, ServerFnError>> { + use crate::schema::subtasks::dsl::*; + + let mut connection = establish_database_connection() + .map_err::, _>( + |_| vec![Error::ServerInternal].into() + )?; + + let results = subtasks + .select(Subtask::as_select()) + .filter(task_id.eq(filtered_task_id)) + .load::(&mut connection) + .map_err::, _>( + |_| vec![Error::ServerInternal].into() + )?; + + Ok(results) +} + +#[server] +pub(crate) async fn edit_subtask(subtask_id: i32, new_subtask: NewSubtask) + -> Result>> { + use crate::schema::subtasks::dsl::*; + + new_subtask.validate() + .map_err::, _>(|errors| errors.into())?; + + let mut connection = establish_database_connection() + .map_err::, _>( + |_| vec![SubtaskError::Error(Error::ServerInternal)].into() + )?; + + let updated_task = diesel::update(subtasks) + .filter(id.eq(subtask_id)) + .set(( + title.eq(new_subtask.title), + is_completed.eq(new_subtask.is_completed) + )) + .returning(Subtask::as_returning()) + .get_result(&mut connection) + .map_err::, _>(|error| vec![error.into()].into())?; + + Ok(updated_task) +} + +#[server] +pub(crate) async fn restore_subtasks_of_task(filtered_task_id: i32) -> Result< + Vec, + ServerFnError> +> { + use crate::schema::subtasks::dsl::*; + + let mut connection = establish_database_connection() + .map_err::, _>( + |_| vec![SubtaskError::Error(Error::ServerInternal)].into() + )?; + + let updated_subtasks = diesel::update(subtasks) + .filter(task_id.eq(filtered_task_id)) + .set(is_completed.eq(false)) + .returning(Subtask::as_returning()) + .get_results(&mut connection) + .map_err::, _>(|error| vec![error.into()].into())?; + + Ok(updated_subtasks) +} + +// TODO: Get rid of this suppression. +//noinspection DuplicatedCode +#[server] +pub(crate) async fn delete_subtask(subtask_id: i32) + -> Result<(), ServerFnError>> { + use crate::schema::subtasks::dsl::*; + + let mut connection = establish_database_connection() + .map_err::, _>(|_| vec![Error::ServerInternal].into())?; + + diesel::delete(subtasks.filter(id.eq(subtask_id))).execute(&mut connection) + .map_err::, _>(|error| vec![error.into()].into())?; + + Ok(()) +}