diff --git a/rust/sqlx-data.json b/rust/sqlx-data.json index c330a39..89808fc 100644 --- a/rust/sqlx-data.json +++ b/rust/sqlx-data.json @@ -56,6 +56,30 @@ }, "query": "\n SELECT\n i_item.id AS item_id\n FROM inventory_items AS i_item\n LEFT JOIN (\n SELECT t_item.item_id as item_id\n FROM trips_items AS t_item\n WHERE t_item.trip_id = ?\n ) AS t_item\n ON t_item.item_id = i_item.id\n WHERE t_item.item_id IS NULL\n " }, + "1eb9a8fdb9412753592f48a9267c97042aeaf6691546efae894e5d34ee92f34b": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + } + ], + "nullable": [ + false, + false + ], + "parameters": { + "Right": 0 + } + }, + "query": "SELECT\n id,\n name\n FROM trips_types" + }, "1f08e9bebf51aab9cabff2a5c79211233a686e9ef9f96ea5c036fbba8f6b06d5": { "describe": { "columns": [ @@ -86,29 +110,35 @@ }, "query": "\n SELECT\n type.id as id,\n type.name as name,\n inner.id IS NOT NULL AS active\n FROM trips_types AS type\n LEFT JOIN (\n SELECT type.id as id, type.name as name\n FROM trips as trip\n INNER JOIN trips_to_trips_types as ttt\n ON ttt.trip_id = trip.id\n INNER JOIN trips_types AS type\n ON type.id == ttt.trip_type_id\n WHERE trip.id = ?\n ) AS inner\n ON inner.id = type.id\n " }, - "4caaa7a80a3c66bb6525bd8ed226a446e131fb19f643b447b4c24824b9561e55": { + "259757f1bb08f0c366371202c75b8555b878290b8a5a68564ec3e8b3d8e7ed01": { "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Text" - } - ], - "nullable": [ - false, - false - ], + "columns": [], + "nullable": [], "parameters": { - "Right": 0 + "Right": 5 } }, - "query": "SELECT\n id,\n name\n FROM trips_types" + "query": "INSERT INTO trips\n (id, name, date_start, date_end, state)\n VALUES\n (?, ?, ?, ?, ?)" + }, + "3356a9ab2f217e3daf101644667c9d84f1547e0c72ab779e2f3aebb628a78034": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 2 + } + }, + "query": "UPDATE trips\n SET state = ?\n WHERE id = ?" + }, + "452cb08b3b46bda9cb62d390d9f518d97626270a26465e55793b0a4b05432e50": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 1 + } + }, + "query": "DELETE FROM inventory_items\n WHERE id = ?" }, "4d377bb01af6bbbca637d8c61326c84e8b05b1e570199c464b593bdc81b3dba6": { "describe": { @@ -180,15 +210,83 @@ }, "query": "\n SELECT COALESCE(MAX(i_item.weight), 0) as weight\n FROM inventory_items_categories as category\n INNER JOIN inventory_items as i_item\n ON i_item.category_id = category.id\n WHERE category_id = ?\n " }, - "68304c19a0bee12c0b3ce9740d53389620b20e47973b41975678dbd13bd30c7f": { + "629f34ef9bd0afa39123758cc182468edb7475f6c08cb4b5febb3ce931f2b547": { "describe": { - "columns": [], - "nullable": [], + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "weight", + "ordinal": 3, + "type_info": "Int64" + }, + { + "name": "category_id", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "category_name", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "category_description", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "product_id", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "product_name", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "product_description", + "ordinal": 9, + "type_info": "Text" + }, + { + "name": "product_comment", + "ordinal": 10, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + true, + false, + false, + false, + true, + true, + true, + true, + true + ], "parameters": { - "Right": 2 + "Right": 1 } }, - "query": "UPDATE trips_types\n SET name = ?\n WHERE id = ?" + "query": "SELECT\n item.id AS id,\n item.name AS name,\n item.description AS description,\n weight,\n category.id AS category_id,\n category.name AS category_name,\n category.description AS category_description,\n product.id AS product_id,\n product.name AS product_name,\n product.description AS product_description,\n product.comment AS product_comment\n FROM inventory_items AS item\n INNER JOIN inventory_items_categories as category\n ON item.category_id = category.id\n LEFT JOIN inventory_products AS product\n ON item.product_id = product.id\n WHERE item.id = ?" }, "6973cceeb5499216475136b320b25e1355974e1213829d931abdd6b7a1448a87": { "describe": { @@ -232,27 +330,7 @@ }, "query": "SELECT\n id,\n name,\n weight,\n description,\n category_id\n FROM inventory_items\n WHERE category_id = ?" }, - "6e2928c8c2e66b15fc3f6f0ae4e8e0d4616b714fccbf273306d5135df31f4c19": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Right": 2 - } - }, - "query": "INSERT INTO inventory_items_categories\n (id, name)\n VALUES\n (?, ?)" - }, - "7746dbbd63e69f7ec8ba5c1036e9ac03b83021be3189bb38ff31131cfbe99534": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Right": 5 - } - }, - "query": "INSERT INTO inventory_items\n (id, name, description, weight, category_id)\n VALUES\n (?, ?, ?, ?, ?)" - }, - "7f23d9e4bb088de4123e93e5287c9743a417aab218d1d9484c0c6f3ac763f772": { + "6df16058d577c24a2aaaae71b2c3fd94ddf24e1ced343f3ea20872f0692a9ada": { "describe": { "columns": [ { @@ -316,7 +394,17 @@ "Right": 0 } }, - "query": "SELECT\n id,\n name,\n CAST (date_start AS TEXT) date_start,\n CAST (date_end AS TEXT) date_end,\n state,\n location,\n temp_min,\n temp_max,\n comment\n FROM trips" + "query": "SELECT\n id,\n name,\n CAST (date_start AS TEXT) date_start,\n CAST (date_end AS TEXT) date_end,\n state,\n location,\n temp_min,\n temp_max,\n comment\n FROM trips" + }, + "6e2928c8c2e66b15fc3f6f0ae4e8e0d4616b714fccbf273306d5135df31f4c19": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 2 + } + }, + "query": "INSERT INTO inventory_items_categories\n (id, name)\n VALUES\n (?, ?)" }, "88293d85c61e1eeaf9e46ada4154736b127c3bf305e92130de87b89ce7c6edab": { "describe": { @@ -328,81 +416,33 @@ }, "query": "UPDATE trips\n SET comment = ?\n WHERE id = ?" }, - "8f2499b0b98e3aa7d8c5925d7898406928572312b034d23ec96acbf19315a74e": { + "918fc9cf50097d4210b212255ef49335ebedbe81002ce9a418b4dab4fbb29aa3": { "describe": { "columns": [ { "name": "id", "ordinal": 0, "type_info": "Text" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "date_start", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "date_end", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "state", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "location", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "temp_min", - "ordinal": 6, - "type_info": "Int64" - }, - { - "name": "temp_max", - "ordinal": 7, - "type_info": "Int64" - }, - { - "name": "comment", - "ordinal": 8, - "type_info": "Text" } ], "nullable": [ - false, - false, - false, - false, - false, - true, - true, - true, - true + false ], "parameters": { "Right": 1 } }, - "query": "SELECT\n id,\n name,\n CAST (date_start AS TEXT) AS date_start,\n CAST (date_end AS TEXT) AS date_end,\n state,\n location,\n temp_min,\n temp_max,\n comment\n FROM trips\n WHERE id = ?" + "query": "SELECT id\n FROM inventory_items\n WHERE name = ?" }, - "982720cc8c246f8cae25106b253394e5492ac968bd604a6fa7f848eee4174696": { + "92ca05be21a4c05bf26f8a1655bbb8d9f1ece322abff5395ecacde3f6e28fdf7": { "describe": { "columns": [], "nullable": [], "parameters": { - "Right": 5 + "Right": 2 } }, - "query": "INSERT INTO trips\n (id, name, date_start, date_end, state)\n VALUES\n (?, ?, ?, ?, ?)" + "query": "UPDATE trips_types\n SET name = ?\n WHERE id = ?" }, "999fe09a6a095ac0ee7b3e3c38a6f2008641e03f9344f31bf9f8eb16a47403da": { "describe": { @@ -500,15 +540,35 @@ }, "query": "SELECT id,name,description FROM inventory_items_categories" }, - "ab7c1b44121defb6c55291ef68958acfb9ba36a63cd7dd1286101d2e6c7065e0": { + "cc1ad49669cff7f89975abfab3d0a8caef2e3978c826e1877db91c05a7f9d00d": { "describe": { - "columns": [], - "nullable": [], + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 2, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + true + ], "parameters": { "Right": 1 } }, - "query": "DELETE FROM inventory_items\n WHERE id = ?" + "query": "SELECT\n id,\n name,\n description\n FROM inventory_items_categories AS category\n WHERE category.id = ?" }, "cc70d7a392a0283fec1896acba805f5c2a527537b8faa22d1c69306017b9c465": { "describe": { @@ -606,6 +666,16 @@ }, "query": "\n SELECT\n category.id as category_id,\n category.name as category_name,\n category.description AS category_description,\n inner.trip_id AS trip_id,\n inner.item_id AS item_id,\n inner.item_name AS item_name,\n inner.item_description AS item_description,\n inner.item_weight AS item_weight,\n inner.item_is_picked AS item_is_picked,\n inner.item_is_packed AS item_is_packed,\n inner.item_is_new AS item_is_new\n FROM inventory_items_categories AS category\n LEFT JOIN (\n SELECT\n trip.trip_id AS trip_id,\n category.id as category_id,\n category.name as category_name,\n category.description as category_description,\n item.id as item_id,\n item.name as item_name,\n item.description as item_description,\n item.weight as item_weight,\n trip.pick as item_is_picked,\n trip.pack as item_is_packed,\n trip.new as item_is_new\n FROM trips_items as trip\n INNER JOIN inventory_items as item\n ON item.id = trip.item_id\n INNER JOIN inventory_items_categories as category\n ON category.id = item.category_id\n WHERE trip.trip_id = ?\n ) AS inner\n ON inner.category_id = category.id\n WHERE category.id = ?\n " }, + "d7c6ae3c6e00c6c99b0bedee87ff237b01007e7001584c82ae896b91833b807b": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 5 + } + }, + "query": "INSERT INTO inventory_items\n (id, name, description, weight, category_id)\n VALUES\n (?, ?, ?, ?, ?)" + }, "ded3be1c8894a64e3b5f749461db7261d9224abb8a54da980db8262733d08205": { "describe": { "columns": [], @@ -616,94 +686,6 @@ }, "query": "INSERT INTO trips_types\n (id, name)\n VALUES\n (?, ?)" }, - "efcf56aacc622556fc10220edb57ea69822eed50cdf9ef54bc48a7fb04d4ee9a": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "description", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "weight", - "ordinal": 3, - "type_info": "Int64" - }, - { - "name": "category_id", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "category_name", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "category_description", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "product_id", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "product_name", - "ordinal": 8, - "type_info": "Text" - }, - { - "name": "product_description", - "ordinal": 9, - "type_info": "Text" - }, - { - "name": "product_comment", - "ordinal": 10, - "type_info": "Text" - } - ], - "nullable": [ - false, - false, - true, - false, - false, - false, - true, - true, - true, - true, - true - ], - "parameters": { - "Right": 1 - } - }, - "query": "SELECT\n item.id AS id,\n item.name AS name,\n item.description AS description,\n weight,\n category.id AS category_id,\n category.name AS category_name,\n category.description AS category_description,\n product.id AS product_id,\n product.name AS product_name,\n product.description AS product_description,\n product.comment AS product_comment\n FROM inventory_items AS item\n INNER JOIN inventory_items_categories as category\n ON item.category_id = category.id\n LEFT JOIN inventory_products AS product\n ON item.product_id = product.id\n WHERE item.id = ?" - }, - "f2038d75ff5ff10d4baeb30b9dc4cc1c991da1facdb1f05e16f271372eee0c7a": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Right": 2 - } - }, - "query": "UPDATE trips\n SET state = ?\n WHERE id = ?" - }, "f24056f8d6e2d483185d71b036ae8a0a1943b8718e8255d826df76ac77ad6326": { "describe": { "columns": [ @@ -859,23 +841,5 @@ } }, "query": "\n SELECT COALESCE(SUM(i_item.weight), 0) as weight\n FROM inventory_items_categories as category\n INNER JOIN inventory_items as i_item\n ON i_item.category_id = category.id\n INNER JOIN trips_items as t_item\n ON i_item.id = t_item.item_id\n WHERE category_id = ?\n AND t_item.pick = 1\n " - }, - "ff260eef6f95a3c1f8e2f822808ac250925dc0971b9bddd9015b8b24643357c9": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Text" - } - ], - "nullable": [ - false - ], - "parameters": { - "Right": 1 - } - }, - "query": "SELECT id\n FROM inventory_items\n WHERE name = ?" } } \ No newline at end of file diff --git a/rust/src/main.rs b/rust/src/main.rs index 32de4ab..2c5df4b 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -21,7 +21,7 @@ use serde_variant::to_variant_name; use sqlx::{ error::DatabaseError, - query, query_as, + query, sqlite::{SqliteConnectOptions, SqliteError, SqlitePoolOptions, SqliteRow}, Pool, Row, Sqlite, }; @@ -363,41 +363,18 @@ async fn inventory_item_validate_name( State(state): State, Form(new_item): Form, ) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { - let results = query!( - "SELECT id - FROM inventory_items - WHERE name = ?", - new_item.name, - ) - .fetch(&state.database_pool) - .map_ok(|_| Ok(())) - .try_collect::>>() - .await - // we have two error handling lines here. these are distinct errors - // this one is the SQL error that may arise during the query - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - components::ErrorPage::build(&e.to_string()), - ) - })? - .into_iter() - .collect::, models::Error>>() - // and this one is the model mapping error that may arise e.g. during - // reading of the rows - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - components::ErrorPage::build(&e.to_string()), - ) - })?; + let exists = models::InventoryItem::name_exists(&state.database_pool, &new_item.name) + .map_err(|error| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + components::ErrorPage::build(&error.to_string()), + ) + }) + .await?; Ok(( StatusCode::OK, - components::inventory::InventoryNewItemFormName::build( - Some(&new_item.name), - !results.is_empty(), - ), + components::inventory::InventoryNewItemFormName::build(Some(&new_item.name), exists), )) } @@ -413,72 +390,23 @@ async fn inventory_item_create( )); } - let id = Uuid::new_v4(); - let id_param = id.to_string(); - let name = &new_item.name; - let category_id = new_item.category_id.to_string(); - query!( - "INSERT INTO inventory_items - (id, name, description, weight, category_id) - VALUES - (?, ?, ?, ?, ?)", - id_param, - name, - "", + let new_id = models::InventoryItem::save( + &state.database_pool, + &new_item.name, + new_item.category_id, new_item.weight, - category_id, ) - .execute(&state.database_pool) - .await - .map_err(|e| match e { - sqlx::Error::Database(ref error) => { - let sqlite_error = error.downcast_ref::(); - if let Some(code) = sqlite_error.code() { - match &*code { - "787" => { - // SQLITE_CONSTRAINT_FOREIGNKEY - ( - StatusCode::BAD_REQUEST, - components::ErrorPage::build(&format!( - "category {id} not found", - id = new_item.category_id - )), - ) - } - "2067" => { - // SQLITE_CONSTRAINT_UNIQUE - ( - StatusCode::BAD_REQUEST, - components::ErrorPage::build(&format!( - "item with name \"{name}\" already exists in category {id}", - name = new_item.name, - id = new_item.category_id - )), - ) - } - _ => ( - StatusCode::INTERNAL_SERVER_ERROR, - components::ErrorPage::build(&format!( - "got error with unknown code: {}", - sqlite_error.to_string() - )), - ), - } - } else { - ( - StatusCode::INTERNAL_SERVER_ERROR, - components::ErrorPage::build(&format!( - "got error without code: {}", - sqlite_error.to_string() - )), - ) - } - } + .map_err(|error| match error { + models::Error::Constraint { description } => ( + StatusCode::BAD_REQUEST, + components::ErrorPage::build(&description), + ), _ => ( StatusCode::INTERNAL_SERVER_ERROR, - components::ErrorPage::build(&format!("got unknown error: {}", e.to_string())), + components::ErrorPage::build(&error.to_string()), ), - })?; + }) + .await?; if is_htmx(&headers) { let inventory = models::Inventory::load(&state.database_pool) @@ -500,7 +428,7 @@ async fn inventory_item_create( .ok_or(( StatusCode::NOT_FOUND, components::ErrorPage::build(&format!( - "a category with id {id} was inserted but does not exist, this is a bug" + "a category with id {new_id} was inserted but does not exist, this is a bug" )), ))?, ); @@ -527,52 +455,24 @@ async fn inventory_item_delete( State(state): State, headers: HeaderMap, Path(id): Path, -) -> Result { - let id_param = id.to_string(); - let results = query!( - "DELETE FROM inventory_items - WHERE id = ?", - id_param, - ) - .execute(&state.database_pool) - .await - .map_err(|error| match error { - sqlx::Error::Database(ref error) => { - let sqlite_error = error.downcast_ref::(); - if let Some(code) = sqlite_error.code() { - match &*code { - "787" => { - // SQLITE_CONSTRAINT_FOREIGNKEY - ( - StatusCode::BAD_REQUEST, - // TODO: this is not perfect, as both foreign keys - // may be responsible for the error. how can we tell - // which one? - format!("item {} cannot be deleted because it's on use in trips. instead, archive it", code.to_string()), - ) - } - _ => ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("got error with unknown code: {}", sqlite_error.to_string()), - ), - } - } else { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("got error without code: {}", sqlite_error.to_string()), - ) - } - } - _ => ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("got unknown error: {}", error.to_string()), - ), - })?; +) -> Result { + let deleted = models::InventoryItem::delete(&state.database_pool, id) + .map_err(|error| match error { + models::Error::Constraint { ref description } => ( + StatusCode::NOT_IMPLEMENTED, + components::ErrorPage::build(description), + ), + _ => ( + StatusCode::INTERNAL_SERVER_ERROR, + components::ErrorPage::build(&error.to_string()), + ), + }) + .await?; - if results.rows_affected() == 0 { + if !deleted { Err(( StatusCode::NOT_FOUND, - format!("item with id {id} not found", id = id), + components::ErrorPage::build(&format!("item with id {id} not found")), )) } else { Ok(Redirect::to( @@ -580,60 +480,22 @@ async fn inventory_item_delete( .get("referer") .ok_or(( StatusCode::BAD_REQUEST, - "no referer header found".to_string(), + components::ErrorPage::build("no referer header found"), ))? .to_str() - .map_err(|e| { + .map_err(|error| { ( StatusCode::BAD_REQUEST, - format!("referer could not be converted: {}", e), + components::ErrorPage::build(&format!( + "referer could not be converted: {}", + error + )), ) })?, )) } } -// async fn htmx_inventory_category_items( -// Path(id): Path, -// ) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { -// let pool = SqlitePoolOptions::new() -// .max_connections(5) -// .connect("sqlite:///home/hannes-private/sync/items/items.sqlite") -// .await -// .unwrap(); - -// let items = query!(&format!( -// //TODO bind this stuff!!!!!!! no sql injection pls -// "SELECT -// i.id, i.name, i.description, i.weight, i.category_id -// FROM inventory_items_categories AS c -// INNER JOIN inventoryitems AS i -// ON i.category_id = c.id WHERE c.id = '{id}';", -// id = id, -// )) -// .fetch(&pool) -// .map_ok(|row| row.try_into()) -// .try_collect::>>() -// .await -// // we have two error handling lines here. these are distinct errors -// // this one is the SQL error that may arise during the query -// .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Html::from(e.to_string())))? -// .into_iter() -// .collect::, models::Error>>() -// // and this one is the model mapping error that may arise e.g. during -// // reading of the rows -// .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Html::from(e.to_string())))?; - -// Ok(( -// StatusCode::OK, -// Html::from( -// InventoryItemList::build(&items) -// .await -// .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Html::from(e.to_string())))? -// .to_string(), -// ), -// )) -// } #[derive(Deserialize)] struct EditItem { #[serde(rename = "edit-item-name")] @@ -711,113 +573,46 @@ struct NewTrip { async fn trip_create( State(state): State, Form(new_trip): Form, -) -> Result { +) -> Result { if new_trip.name.is_empty() { return Err(( StatusCode::UNPROCESSABLE_ENTITY, - "name cannot be empty".to_string(), + components::ErrorPage::build("name cannot be empty"), )); } - let id = Uuid::new_v4(); - let id_param = id.to_string(); - let date_start = new_trip - .date_start - .format(models::DATE_FORMAT) - .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; - let date_end = new_trip - .date_end - .format(models::DATE_FORMAT) - .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; - let trip_state = models::TripState::new(); - query!( - "INSERT INTO trips - (id, name, date_start, date_end, state) - VALUES - (?, ?, ?, ?, ?)", - id_param, - new_trip.name, - date_start, - date_end, - trip_state + let new_id = models::Trip::save( + &state.database_pool, + &new_trip.name, + new_trip.date_start, + new_trip.date_end, ) - .execute(&state.database_pool) - .await - .map_err(|e| match e { - sqlx::Error::Database(ref error) => { - let sqlite_error = error.downcast_ref::(); - if let Some(code) = sqlite_error.code() { - match &*code { - "2067" => { - // SQLITE_CONSTRAINT_UNIQUE - ( - StatusCode::BAD_REQUEST, - format!( - "trip with name \"{name}\" already exists", - name = new_trip.name, - ), - ) - } - _ => ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("got error with unknown code: {}", sqlite_error.to_string()), - ), - } - } else { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("got error without code: {}", sqlite_error.to_string()), - ) - } - } - _ => ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("got unknown error: {}", e.to_string()), + .map_err(|error| match error { + models::Error::TimeParse { description } => ( + StatusCode::BAD_REQUEST, + components::ErrorPage::build(&description), ), - })?; + _ => ( + StatusCode::BAD_REQUEST, + components::ErrorPage::build(&error.to_string()), + ), + }) + .await?; - Ok(Redirect::to(&format!("/trips/{id}/", id = id))) + Ok(Redirect::to(&format!("/trips/{new_id}/"))) } async fn trips( State(state): State, ) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { - let trips: Vec = query_as!( - models::DbTripRow, - "SELECT - id, - name, - CAST (date_start AS TEXT) date_start, - CAST (date_end AS TEXT) date_end, - state, - location, - temp_min, - temp_max, - comment - FROM trips", - ) - .fetch(&state.database_pool) - .map_ok(|row| row.try_into()) - .try_collect::>>() - .await - // we have two error handling lines here. these are distinct errors - // this one is the SQL error that may arise during the query - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - components::ErrorPage::build(&e.to_string()), - ) - })? - .into_iter() - .collect::, models::Error>>() - // and this one is the model mapping error that may arise e.g. during - // reading of the rows - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - components::ErrorPage::build(&e.to_string()), - ) - })?; + let trips = models::Trip::all(&state.database_pool) + .map_err(|error| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + components::ErrorPage::build(&error.to_string()), + ) + }) + .await?; Ok(( StatusCode::OK, @@ -842,42 +637,18 @@ async fn trip( state.client_state.trip_edit_attribute = trip_query.edit; state.client_state.active_category_id = trip_query.category; - let id_param = id.to_string(); - let mut trip: models::Trip = query_as!( - models::DbTripRow, - "SELECT - id, - name, - CAST (date_start AS TEXT) AS date_start, - CAST (date_end AS TEXT) AS date_end, - state, - location, - temp_min, - temp_max, - comment - FROM trips - WHERE id = ?", - id_param, - ) - .fetch_one(&state.database_pool) - .map_ok(|row| row.try_into()) - .await - .map_err(|e: sqlx::Error| match e { - sqlx::Error::RowNotFound => ( + let mut trip: models::Trip = models::Trip::find(&state.database_pool, id) + .map_err(|error| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + components::ErrorPage::build(&error.to_string()), + ) + }) + .await? + .ok_or(( StatusCode::NOT_FOUND, components::ErrorPage::build(&format!("trip with id {} not found", id)), - ), - _ => ( - StatusCode::INTERNAL_SERVER_ERROR, - components::ErrorPage::build(&e.to_string()), - ), - })? - .map_err(|e: models::Error| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - components::ErrorPage::build(&e.to_string()), - ) - })?; + ))?; trip.load_trips_types(&state.database_pool) .await @@ -1555,24 +1326,16 @@ async fn trip_state_set( headers: HeaderMap, Path((trip_id, new_state)): Path<(Uuid, models::TripState)>, ) -> Result { - let trip_id = trip_id.to_string(); - let result = query!( - "UPDATE trips - SET state = ? - WHERE id = ?", - new_state, - trip_id, - ) - .execute(&state.database_pool) - .await - .map_err(|e| { - ( - StatusCode::BAD_REQUEST, - components::ErrorPage::build(&e.to_string()), - ) - })?; + let exists = models::Trip::set_state(&state.database_pool, trip_id, &new_state) + .map_err(|e| { + ( + StatusCode::BAD_REQUEST, + components::ErrorPage::build(&e.to_string()), + ) + }) + .await?; - if result.rows_affected() == 0 { + if !exists { return Err(( StatusCode::NOT_FOUND, components::ErrorPage::build(&format!("trip with id {id} not found", id = trip_id)), @@ -1608,35 +1371,14 @@ async fn trips_types( ) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { state.client_state.trip_type_edit = trip_type_query.edit; - let trip_types: Vec = query_as!( - models::DbTripsTypesRow, - "SELECT - id, - name - FROM trips_types", - ) - .fetch(&state.database_pool) - .map_ok(|row| row.try_into()) - .try_collect::>>() - .await - // we have two error handling lines here. these are distinct errors - // this one is the SQL error that may arise during the query - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - components::ErrorPage::build(&e.to_string()), - ) - })? - .into_iter() - .collect::, models::Error>>() - // and this one is the model mapping error that may arise e.g. during - // reading of the rows - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - components::ErrorPage::build(&e.to_string()), - ) - })?; + let trip_types: Vec = models::TripsType::all(&state.database_pool) + .map_err(|error| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + components::ErrorPage::build(&error.to_string()), + ) + }) + .await?; Ok(( StatusCode::OK, @@ -1730,24 +1472,17 @@ async fn trips_types_edit_name( )); } - let id_param = trip_type_id.to_string(); - let result = query!( - "UPDATE trips_types - SET name = ? - WHERE id = ?", - trip_update.new_value, - id_param, - ) - .execute(&state.database_pool) - .await - .map_err(|e| { - ( - StatusCode::BAD_REQUEST, - components::ErrorPage::build(&e.to_string()), - ) - })?; + let exists = + models::TripsType::set_name(&state.database_pool, trip_type_id, &trip_update.new_value) + .map_err(|error| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + components::ErrorPage::build(&error.to_string()), + ) + }) + .await?; - if result.rows_affected() == 0 { + if !exists { Err(( StatusCode::NOT_FOUND, components::ErrorPage::build(&format!( @@ -1764,48 +1499,18 @@ async fn inventory_item( State(state): State, Path(id): Path, ) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { - let id_param = id.to_string(); - let item: models::InventoryItem = query_as!( - models::DbInventoryItemRow, - "SELECT - item.id AS id, - item.name AS name, - item.description AS description, - weight, - category.id AS category_id, - category.name AS category_name, - category.description AS category_description, - product.id AS product_id, - product.name AS product_name, - product.description AS product_description, - product.comment AS product_comment - FROM inventory_items AS item - INNER JOIN inventory_items_categories as category - ON item.category_id = category.id - LEFT JOIN inventory_products AS product - ON item.product_id = product.id - WHERE item.id = ?", - id_param, - ) - .fetch_one(&state.database_pool) - .map_ok(|row| row.try_into()) - .await - .map_err(|e: sqlx::Error| match e { - sqlx::Error::RowNotFound => ( + let item = models::InventoryItem::find(&state.database_pool, id) + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + components::ErrorPage::build(&e.to_string()), + ) + }) + .await? + .ok_or(( StatusCode::NOT_FOUND, - components::ErrorPage::build(&format!("item with id {} not found", id)), - ), - _ => ( - StatusCode::INTERNAL_SERVER_ERROR, - components::ErrorPage::build(&e.to_string()), - ), - })? - .map_err(|e: models::Error| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - components::ErrorPage::build(&e.to_string()), - ) - })?; + components::ErrorPage::build(&format!("inventory item with id {id} not found")), + ))?; Ok(( StatusCode::OK, diff --git a/rust/src/models.rs b/rust/src/models.rs index ded580d..3236c26 100644 --- a/rust/src/models.rs +++ b/rust/src/models.rs @@ -12,7 +12,7 @@ use std::num::TryFromIntError; use std::str::FromStr; use uuid::Uuid; -use sqlx::sqlite::SqlitePoolOptions; +use sqlx::{error::DatabaseError, sqlite::SqlitePoolOptions}; use futures::TryFutureExt; use futures::TryStreamExt; @@ -26,6 +26,7 @@ pub enum Error { Uuid { description: String }, Enum { description: String }, Int { description: String }, + Constraint { description: String }, TimeParse { description: String }, } @@ -47,6 +48,9 @@ impl fmt::Display for Error { Self::TimeParse { description } => { write!(f, "Date parse error: {description}") } + Self::Constraint { description } => { + write!(f, "SQL constraint error: {description}") + } } } } @@ -320,7 +324,7 @@ pub struct TripItem { pub new: bool, } -pub struct DbTripsItemsRow { +struct DbTripsItemsRow { picked: bool, packed: bool, new: bool, @@ -393,7 +397,7 @@ impl TripItem { } } -pub struct DbTripRow { +struct DbTripRow { pub id: String, pub name: String, pub date_start: String, @@ -455,11 +459,34 @@ pub enum TripAttribute { TempMax, } -pub struct DbTripWeightRow { +struct DbTripWeightRow { pub total_weight: Option, } impl<'a> Trip { + pub async fn all(pool: &sqlx::Pool) -> Result, Error> { + sqlx::query_as!( + DbTripRow, + "SELECT + id, + name, + CAST (date_start AS TEXT) date_start, + CAST (date_end AS TEXT) date_end, + state, + location, + temp_min, + temp_max, + comment + FROM trips", + ) + .fetch(pool) + .map_ok(|row| row.try_into()) + .try_collect::>>() + .await? + .into_iter() + .collect::, Error>>() + } + pub async fn find( pool: &sqlx::Pool, trip_id: Uuid, @@ -494,6 +521,96 @@ impl<'a> Trip { } } + pub async fn set_state( + pool: &sqlx::Pool, + id: Uuid, + new_state: &TripState, + ) -> Result { + let trip_id_param = id.to_string(); + let result = sqlx::query!( + "UPDATE trips + SET state = ? + WHERE id = ?", + new_state, + trip_id_param, + ) + .execute(pool) + .await?; + + Ok(result.rows_affected() != 0) + } + + pub async fn save( + pool: &sqlx::Pool, + name: &str, + date_start: time::Date, + date_end: time::Date, + ) -> Result { + let id = Uuid::new_v4(); + let id_param = id.to_string(); + let date_start = date_start + .format(DATE_FORMAT) + .map_err(|e| Error::TimeParse { + description: e.to_string(), + })?; + let date_end = date_end.format(DATE_FORMAT).map_err(|e| Error::TimeParse { + description: e.to_string(), + })?; + + let trip_state = TripState::new(); + + sqlx::query!( + "INSERT INTO trips + (id, name, date_start, date_end, state) + VALUES + (?, ?, ?, ?, ?)", + id_param, + name, + date_start, + date_end, + trip_state, + ) + .execute(pool) + .await + .map_err(|e| match e { + sqlx::Error::Database(ref error) => { + let sqlite_error = error.downcast_ref::(); + if let Some(code) = sqlite_error.code() { + match &*code { + // SQLITE_CONSTRAINT_FOREIGNKEY + "787" => Error::Constraint { + description: format!( + "SQLITE_CONSTRAINT_FOREIGNKEY on table without foreignkey?", + ), + }, + // SQLITE_CONSTRAINT_UNIQUE + "2067" => Error::Constraint { + description: format!("trip with name \"{name}\" already exists",), + }, + _ => Error::Sql { + description: format!( + "got error with unknown code: {}", + sqlite_error.to_string() + ), + }, + } + } else { + Error::Sql { + description: format!( + "got error without code: {}", + sqlite_error.to_string() + ), + } + } + } + _ => Error::Sql { + description: format!("got unknown error: {}", e.to_string()), + }, + })?; + + Ok(id) + } + pub async fn find_total_picked_weight( pool: &sqlx::Pool, trip_id: Uuid, @@ -788,7 +905,45 @@ pub struct TripType { pub active: bool, } -pub struct DbCategoryRow { +impl TripsType { + pub async fn all(pool: &sqlx::Pool) -> Result, Error> { + sqlx::query_as!( + DbTripsTypesRow, + "SELECT + id, + name + FROM trips_types", + ) + .fetch(pool) + .map_ok(|row| row.try_into()) + .try_collect::>>() + .await? + .into_iter() + .collect::, Error>>() + } + + pub async fn set_name( + pool: &sqlx::Pool, + id: Uuid, + new_name: &str, + ) -> Result { + let id_param = id.to_string(); + + let result = sqlx::query!( + "UPDATE trips_types + SET name = ? + WHERE id = ?", + new_name, + id_param, + ) + .execute(pool) + .await?; + + Ok(result.rows_affected() != 0) + } +} + +struct DbCategoryRow { pub id: String, pub name: String, pub description: Option, @@ -815,7 +970,7 @@ impl TryFrom for Category { } } -pub struct DbInventoryItemsRow { +struct DbInventoryItemsRow { id: String, name: String, weight: i64, @@ -824,33 +979,33 @@ pub struct DbInventoryItemsRow { } impl<'a> Category { - // pub async fn find( - // pool: &sqlx::Pool, - // id: Uuid, - // ) -> Result, Error> { - // let id_param = id.to_string(); - // let item: Result, sqlx::Error> = sqlx::query_as!( - // DbCategoryRow, - // "SELECT - // id, - // name, - // description - // FROM inventory_items_categories AS category - // WHERE category.id = ?", - // id_param, - // ) - // .fetch_one(pool) - // .map_ok(|row| row.try_into()) - // .await; + pub async fn _find( + pool: &sqlx::Pool, + id: Uuid, + ) -> Result, Error> { + let id_param = id.to_string(); + let item: Result, sqlx::Error> = sqlx::query_as!( + DbCategoryRow, + "SELECT + id, + name, + description + FROM inventory_items_categories AS category + WHERE category.id = ?", + id_param, + ) + .fetch_one(pool) + .map_ok(|row| row.try_into()) + .await; - // match item { - // Err(e) => match e { - // sqlx::Error::RowNotFound => Ok(None), - // _ => Err(e.into()), - // }, - // Ok(v) => Ok(Some(v?)), - // } - // } + match item { + Err(e) => match e { + sqlx::Error::RowNotFound => Ok(None), + _ => Err(e.into()), + }, + Ok(v) => Ok(Some(v?)), + } + } pub fn items(&'a self) -> &'a Vec { self.items @@ -1039,7 +1194,7 @@ impl Item { } } -pub struct DbTripsTypesRow { +struct DbTripsTypesRow { pub id: String, pub name: String, } @@ -1082,7 +1237,7 @@ pub struct InventoryItem { pub product: Option, } -pub struct DbInventoryItemRow { +struct DbInventoryItemRow { pub id: String, pub name: String, pub description: Option, @@ -1126,6 +1281,160 @@ impl TryFrom for InventoryItem { } } +impl InventoryItem { + pub async fn find(pool: &sqlx::Pool, id: Uuid) -> Result, Error> { + let id_param = id.to_string(); + + let item: Result, sqlx::Error> = sqlx::query_as!( + DbInventoryItemRow, + "SELECT + item.id AS id, + item.name AS name, + item.description AS description, + weight, + category.id AS category_id, + category.name AS category_name, + category.description AS category_description, + product.id AS product_id, + product.name AS product_name, + product.description AS product_description, + product.comment AS product_comment + FROM inventory_items AS item + INNER JOIN inventory_items_categories as category + ON item.category_id = category.id + LEFT JOIN inventory_products AS product + ON item.product_id = product.id + WHERE item.id = ?", + id_param, + ) + .fetch_one(pool) + .map_ok(|row| row.try_into()) + .await; + + match item { + Err(e) => match e { + sqlx::Error::RowNotFound => Ok(None), + _ => Err(e.into()), + }, + Ok(v) => Ok(Some(v?)), + } + } + + pub async fn name_exists(pool: &sqlx::Pool, name: &str) -> Result { + let item: Result<(), sqlx::Error> = sqlx::query!( + "SELECT id + FROM inventory_items + WHERE name = ?", + name, + ) + .fetch_one(pool) + .map_ok(|_row| ()) + .await; + + match item { + Err(e) => match e { + sqlx::Error::RowNotFound => Ok(false), + _ => Err(e.into()), + }, + Ok(_) => Ok(true), + } + } + + pub async fn delete(pool: &sqlx::Pool, id: Uuid) -> Result { + let id_param = id.to_string(); + let results = sqlx::query!( + "DELETE FROM inventory_items + WHERE id = ?", + id_param + ) + .execute(pool) + .map_err(|error| match error { + sqlx::Error::Database(ref error) => { + let sqlite_error = error.downcast_ref::(); + if let Some(code) = sqlite_error.code() { + match &*code { + "787" => { + // SQLITE_CONSTRAINT_FOREIGNKEY + Error::Constraint { description: format!("item {} cannot be deleted because it's on use in trips. instead, archive it", code.to_string()) } + } + _ => + Error::Sql { description: format!("got error with unknown code: {}", sqlite_error.to_string()) } + } + } else { + Error::Constraint { description: format!("got error without code: {}", sqlite_error.to_string()) } + } + } + _ => Error::Constraint { description: format!("got unknown error: {}", error.to_string()) } + }).await?; + + Ok(results.rows_affected() != 0) + } + + pub async fn save( + pool: &sqlx::Pool, + name: &str, + category_id: Uuid, + weight: u32, + ) -> Result { + let id = Uuid::new_v4(); + let id_param = id.to_string(); + let category_id_param = category_id.to_string(); + + sqlx::query!( + "INSERT INTO inventory_items + (id, name, description, weight, category_id) + VALUES + (?, ?, ?, ?, ?)", + id_param, + name, + "", + weight, + category_id_param + ) + .execute(pool) + .await + .map_err(|e| match e { + sqlx::Error::Database(ref error) => { + let sqlite_error = error.downcast_ref::(); + if let Some(code) = sqlite_error.code() { + match &*code { + // SQLITE_CONSTRAINT_FOREIGNKEY + "787" => Error::Constraint { + description: format!( + "category {category_id} not found", + ), + }, + // SQLITE_CONSTRAINT_UNIQUE + "2067" => Error::Constraint { + description: format!( + "item with name \"{name}\" already exists in category {category_id}", + ), + }, + _ => Error::Sql { + description: format!( + "got error with unknown code: {}", + sqlite_error.to_string() + ), + }, + } + } else { + Error::Sql { + description: format!( + "got error without code: {}", + sqlite_error.to_string() + ), + } + } + } + _ => Error::Sql { + description: format!("got unknown error: {}", e.to_string()), + }, + })?; + + Ok(id) + } +} + pub struct Inventory { pub categories: Vec, }