use serde::{Deserialize, Serialize}; use sqlx::{ database::Database, database::HasValueRef, sqlite::{Sqlite, SqliteRow}, Decode, Row, }; use std::convert; use std::error; use std::fmt; use std::num::TryFromIntError; use std::str::FromStr; use uuid::Uuid; use sqlx::sqlite::SqlitePoolOptions; use futures::TryFutureExt; use futures::TryStreamExt; use time::{error::Parse as TimeParse, format_description::FormatItem, macros::format_description}; pub const DATE_FORMAT: &[FormatItem<'static>] = format_description!("[year]-[month]-[day]"); pub enum Error { Sql { description: String }, Uuid { description: String }, Enum { description: String }, Int { description: String }, TimeParse { description: String }, } impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { Self::Sql { description } => { write!(f, "SQL error: {description}") } Self::Uuid { description } => { write!(f, "UUID error: {description}") } Self::Int { description } => { write!(f, "Integer error: {description}") } Self::Enum { description } => { write!(f, "Enum error: {description}") } Self::TimeParse { description } => { write!(f, "Date parse error: {description}") } } } } impl fmt::Debug for Error { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { // defer to Display write!(f, "SQL error: {self}") } } impl convert::From for Error { fn from(value: uuid::Error) -> Self { Error::Uuid { description: value.to_string(), } } } impl convert::From for Error { fn from(value: sqlx::Error) -> Self { Error::Sql { description: value.to_string(), } } } impl convert::From for Error { fn from(value: TryFromIntError) -> Self { Error::Int { description: value.to_string(), } } } impl convert::From for Error { fn from(value: TimeParse) -> Self { Error::TimeParse { description: value.to_string(), } } } impl error::Error for Error {} #[derive(sqlx::Type, PartialEq, PartialOrd, Deserialize)] pub enum TripState { Init, Planning, Planned, Active, Review, Done, } impl TripState { pub fn new() -> Self { TripState::Init } pub fn next(&self) -> Option { match self { Self::Init => Some(Self::Planning), Self::Planning => Some(Self::Planned), Self::Planned => Some(Self::Active), Self::Active => Some(Self::Review), Self::Review => Some(Self::Done), Self::Done => None, } } pub fn prev(&self) -> Option { match self { Self::Init => None, Self::Planning => Some(Self::Init), Self::Planned => Some(Self::Planning), Self::Active => Some(Self::Planned), Self::Review => Some(Self::Active), Self::Done => Some(Self::Review), } } } impl fmt::Display for TripState { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!( f, "{}", match self { Self::Init => "Init", Self::Planning => "Planning", Self::Planned => "Planned", Self::Active => "Active", Self::Review => "Review", Self::Done => "Done", }, ) } } impl std::convert::TryFrom<&str> for TripState { type Error = Error; fn try_from(value: &str) -> Result { Ok(match value { "Init" => Self::Init, "Planning" => Self::Planning, "Planned" => Self::Planned, "Active" => Self::Active, "Review" => Self::Review, "Done" => Self::Done, _ => { return Err(Error::Enum { description: format!("{value} is not a valid value for TripState"), }) } }) } } #[derive(Serialize, Debug)] pub enum TripItemStateKey { Pick, Pack, } impl fmt::Display for TripItemStateKey { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!( f, "{}", match self { Self::Pick => "pick", Self::Pack => "pack", }, ) } } #[derive(Debug)] pub struct TripCategory { pub category: Category, pub items: Option>, } impl TripCategory { pub fn total_picked_weight(&self) -> i64 { self.items .as_ref() .unwrap() .iter() .filter(|item| item.picked) .map(|item| item.item.weight) .sum() } pub async fn find( pool: &sqlx::Pool, trip_id: Uuid, category_id: Uuid, ) -> Result, Error> { let mut category: Option = None; let trip_id_param = trip_id.to_string(); let category_id_param = category_id.to_string(); sqlx::query!( " SELECT category.id as category_id, category.name as category_name, category.description AS category_description, inner.trip_id AS trip_id, inner.item_id AS item_id, inner.item_name AS item_name, inner.item_description AS item_description, inner.item_weight AS item_weight, inner.item_is_picked AS item_is_picked, inner.item_is_packed AS item_is_packed, inner.item_is_new AS item_is_new FROM inventory_items_categories AS category LEFT JOIN ( SELECT trip.trip_id AS trip_id, category.id as category_id, category.name as category_name, category.description as category_description, item.id as item_id, item.name as item_name, item.description as item_description, item.weight as item_weight, trip.pick as item_is_picked, trip.pack as item_is_packed, trip.new as item_is_new FROM trips_items as trip INNER JOIN inventory_items as item ON item.id = trip.item_id INNER JOIN inventory_items_categories as category ON category.id = item.category_id WHERE trip.trip_id = ? ) AS inner ON inner.category_id = category.id WHERE category.id = ? ", trip_id_param, category_id_param ) .fetch(pool) .map_ok(|row| -> Result<(), Error> { match &category { Some(_) => (), None => { category = Some(TripCategory { category: Category { id: Uuid::try_parse(&row.category_id)?, name: row.category_name, description: row.category_description, items: None, }, items: None, }) } }; match row.item_id { None => { // we have an empty (unused) category which has NULL values // for the item_id column category.as_mut().unwrap().items = Some(vec![]); category.as_mut().unwrap().category.items = Some(vec![]); } Some(item_id) => { let item = TripItem { item: Item { id: Uuid::try_parse(&item_id)?, name: row.item_name.unwrap(), description: row.item_description, weight: row.item_weight.unwrap(), category_id: category.as_ref().unwrap().category.id, }, picked: row.item_is_picked.unwrap(), packed: row.item_is_packed.unwrap(), new: row.item_is_new.unwrap(), }; match &mut category.as_mut().unwrap().items { None => category.as_mut().unwrap().items = Some(vec![item]), Some(ref mut items) => items.push(item), } } } Ok(()) }) .try_collect::>>() .await? .into_iter() .collect::>()?; // this may be None if there are no results (which // means that the category was not found) Ok(category) } } #[derive(Debug)] pub struct TripItem { pub item: Item, pub picked: bool, pub packed: bool, pub new: bool, } pub struct DbTripsItemsRow { picked: bool, packed: bool, new: bool, id: String, name: String, weight: i64, description: Option, category_id: String, } impl TryFrom for TripItem { type Error = Error; fn try_from(row: DbTripsItemsRow) -> Result { Ok(TripItem { picked: row.picked, packed: row.packed, new: row.new, item: Item { id: Uuid::try_parse(&row.id)?, name: row.name, description: row.description, weight: row.weight, category_id: Uuid::try_parse(&row.category_id)?, }, }) } } impl TripItem { pub async fn find( pool: &sqlx::Pool, trip_id: Uuid, item_id: Uuid, ) -> Result, Error> { let item_id_param = item_id.to_string(); let trip_id_param = trip_id.to_string(); let item: Result, sqlx::Error> = sqlx::query_as!( DbTripsItemsRow, " SELECT t_item.item_id AS id, t_item.pick AS picked, t_item.pack AS packed, t_item.new AS new, i_item.name AS name, i_item.description AS description, i_item.weight AS weight, i_item.category_id AS category_id FROM trips_items AS t_item INNER JOIN inventory_items AS i_item ON i_item.id = t_item.item_id WHERE t_item.item_id = ? AND t_item.trip_id = ? ", item_id_param, trip_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 struct DbTripRow { pub id: String, pub name: String, pub date_start: String, pub date_end: String, pub state: String, pub location: Option, pub temp_min: Option, pub temp_max: Option, pub comment: Option, } impl TryFrom for Trip { type Error = Error; fn try_from(row: DbTripRow) -> Result { Ok(Trip { id: Uuid::try_parse(&row.id)?, name: row.name, date_start: time::Date::parse(&row.date_start, DATE_FORMAT)?, date_end: time::Date::parse(&row.date_end, DATE_FORMAT)?, state: row.state.as_str().try_into()?, location: row.location, temp_min: row.temp_min, temp_max: row.temp_max, comment: row.comment, types: None, categories: None, }) } } pub struct Trip { pub id: Uuid, pub name: String, pub date_start: time::Date, pub date_end: time::Date, pub state: TripState, pub location: Option, pub temp_min: Option, pub temp_max: Option, pub comment: Option, types: Option>, categories: Option>, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub enum TripAttribute { #[serde(rename = "name")] Name, #[serde(rename = "date_start")] DateStart, #[serde(rename = "date_end")] DateEnd, #[serde(rename = "location")] Location, #[serde(rename = "temp_min")] TempMin, #[serde(rename = "temp_max")] TempMax, } pub struct DbTripWeightRow { pub total_weight: Option, } impl<'a> Trip { pub async fn find( pool: &sqlx::Pool, trip_id: Uuid, ) -> Result, Error> { let trip_id_param = trip_id.to_string(); let trip = 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 WHERE id = ?", trip_id_param ) .fetch_one(pool) .map_ok(|row| row.try_into()) .await; match trip { Err(e) => match e { sqlx::Error::RowNotFound => Ok(None), _ => Err(e.into()), }, Ok(v) => Ok(Some(v?)), } } pub async fn find_total_picked_weight( pool: &sqlx::Pool, trip_id: Uuid, ) -> Result, Error> { let trip_id_param = trip_id.to_string(); let weight = sqlx::query_as!( DbTripWeightRow, " SELECT CAST(IFNULL(SUM(i_item.weight), 0) AS INTEGER) AS total_weight FROM trips AS trip INNER JOIN trips_items AS t_item ON t_item.trip_id = trip.id INNER JOIN inventory_items AS i_item ON t_item.item_id = i_item.id WHERE trip.id = ? AND t_item.pick = true ", trip_id_param ) .fetch_one(pool) .map_ok(|row| row.total_weight.map(|weight| weight as i64)) .await; match weight { Err(e) => match e { sqlx::Error::RowNotFound => Ok(None), _ => Err(e.into()), }, Ok(v) => Ok(v), } } pub fn types(&'a self) -> &Vec { self.types .as_ref() .expect("you need to call load_trips_types()") } pub fn categories(&'a self) -> &Vec { self.categories .as_ref() .expect("you need to call load_trips_types()") } pub fn total_picked_weight(&self) -> i64 { self.categories() .iter() .map(|category| -> i64 { category .items .as_ref() .unwrap() .iter() .filter_map(|item| Some(item.item.weight).filter(|_| item.picked)) .sum::() }) .sum::() } pub async fn load_trips_types( &'a mut self, pool: &sqlx::Pool, ) -> Result<(), Error> { let id = self.id.to_string(); let types = sqlx::query!( " SELECT type.id as id, type.name as name, inner.id IS NOT NULL AS active FROM trips_types AS type LEFT JOIN ( SELECT type.id as id, type.name as name FROM trips as trip INNER JOIN trips_to_trips_types as ttt ON ttt.trip_id = trip.id INNER JOIN trips_types AS type ON type.id == ttt.trip_type_id WHERE trip.id = ? ) AS inner ON inner.id = type.id ", id ) .fetch(pool) .map_ok(|row| -> Result { Ok(TripType { id: Uuid::try_parse(&row.id)?, name: row.name, active: match row.active { 0 => false, 1 => true, _ => unreachable!(), }, }) }) .try_collect::>>() .await? .into_iter() .collect::, Error>>()?; self.types = Some(types); Ok(()) } pub async fn sync_trip_items_with_inventory( &'a mut self, pool: &sqlx::Pool, ) -> Result<(), Error> { // we need to get all items that are part of the inventory but not // part of the trip items // // then, we know which items we need to sync. there are different // states for them: // // * if the trip is new (it's state is INITIAL), we can just forward // as-is // * if the trip is new, we have to make these new items prominently // visible so the user knows that there might be new items to // consider let trip_id = self.id.to_string(); let unsynced_items: Vec = sqlx::query!( " SELECT i_item.id AS item_id FROM inventory_items AS i_item LEFT JOIN ( SELECT t_item.item_id as item_id FROM trips_items AS t_item WHERE t_item.trip_id = ? ) AS t_item ON t_item.item_id = i_item.id WHERE t_item.item_id IS NULL ", trip_id ) .fetch(pool) .map_ok(|row| -> Result { Ok(Uuid::try_parse(&row.item_id)?) }) .try_collect::>>() .await? .into_iter() .collect::, Error>>()?; // looks like there is currently no nice way to do multiple inserts // with sqlx. whatever, this won't matter // only mark as new when the trip is already underway let mark_as_new = self.state != TripState::new(); for unsynced_item in &unsynced_items { let item_id = unsynced_item.to_string(); sqlx::query!( " INSERT INTO trips_items ( item_id, trip_id, pick, pack, new ) VALUES (?, ?, ?, ?, ?) ", item_id, trip_id, false, false, mark_as_new, ) .execute(pool) .await?; } tracing::info!("unsynced items: {:?}", &unsynced_items); Ok(()) } pub async fn load_categories( &'a mut self, pool: &sqlx::Pool, ) -> Result<(), Error> { let mut categories: Vec = vec![]; // we can ignore the return type as we collect into `categories` // in the `map_ok()` closure let id = self.id.to_string(); sqlx::query!( " SELECT category.id as category_id, category.name as category_name, category.description AS category_description, inner.trip_id AS trip_id, inner.item_id AS item_id, inner.item_name AS item_name, inner.item_description AS item_description, inner.item_weight AS item_weight, inner.item_is_picked AS item_is_picked, inner.item_is_packed AS item_is_packed, inner.item_is_new AS item_is_new FROM inventory_items_categories AS category LEFT JOIN ( SELECT trip.trip_id AS trip_id, category.id as category_id, category.name as category_name, category.description as category_description, item.id as item_id, item.name as item_name, item.description as item_description, item.weight as item_weight, trip.pick as item_is_picked, trip.pack as item_is_packed, trip.new as item_is_new FROM trips_items as trip INNER JOIN inventory_items as item ON item.id = trip.item_id INNER JOIN inventory_items_categories as category ON category.id = item.category_id WHERE trip.trip_id = ? ) AS inner ON inner.category_id = category.id ", id ) .fetch(pool) .map_ok(|row| -> Result<(), Error> { let mut category = TripCategory { category: Category { id: Uuid::try_parse(&row.category_id)?, name: row.category_name, description: row.category_description, items: None, }, items: None, }; match row.item_id { None => { // we have an empty (unused) category which has NULL values // for the item_id column category.items = Some(vec![]); categories.push(category); } Some(item_id) => { let item = TripItem { item: Item { id: Uuid::try_parse(&item_id)?, name: row.item_name.unwrap(), description: row.item_description, weight: row.item_weight.unwrap(), category_id: category.category.id, }, picked: row.item_is_picked.unwrap(), packed: row.item_is_packed.unwrap(), new: row.item_is_new.unwrap(), }; if let Some(&mut ref mut c) = categories .iter_mut() .find(|c| c.category.id == category.category.id) { // we always populate c.items when we add a new category, so // it's safe to unwrap here c.items.as_mut().unwrap().push(item); } else { category.items = Some(vec![item]); categories.push(category); } } } Ok(()) }) .try_collect::>>() .await? .into_iter() .collect::>()?; self.categories = Some(categories); Ok(()) } } pub struct TripType { pub id: Uuid, pub name: String, pub active: bool, } pub struct DbCategoryRow { pub id: String, pub name: String, pub description: Option, } #[derive(Debug)] pub struct Category { pub id: Uuid, pub name: String, pub description: Option, items: Option>, } impl TryFrom for Category { type Error = Error; fn try_from(row: DbCategoryRow) -> Result { Ok(Category { id: Uuid::try_parse(&row.id)?, name: row.name, description: row.description, items: None, }) } } pub struct DbInventoryItemsRow { id: String, name: String, weight: i64, description: Option, category_id: String, } 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; // 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 .as_ref() .expect("you need to call populate_items()") } pub fn total_weight(&self) -> i64 { self.items().iter().map(|item| item.weight).sum() } pub async fn populate_items( &'a mut self, pool: &sqlx::Pool, ) -> Result<(), Error> { let id = self.id.to_string(); let items = sqlx::query_as!( DbInventoryItemsRow, "SELECT id, name, weight, description, category_id FROM inventory_items WHERE category_id = ?", id ) .fetch(pool) .map_ok(|row| row.try_into()) .try_collect::>>() .await? .into_iter() .collect::, Error>>()?; self.items = Some(items); Ok(()) } } #[derive(Debug)] pub struct Item { pub id: Uuid, pub name: String, pub description: Option, pub weight: i64, pub category_id: Uuid, } impl TryFrom for Item { type Error = Error; fn try_from(row: DbInventoryItemsRow) -> Result { Ok(Item { id: Uuid::try_parse(&row.id)?, name: row.name, description: row.description, // TODO weight: row.weight, category_id: Uuid::try_parse(&row.category_id)?, }) } } impl Item { 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!( DbInventoryItemsRow, "SELECT id, name, weight, description, category_id FROM inventory_items AS item 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 update( pool: &sqlx::Pool, id: Uuid, name: &str, weight: i64, ) -> Result, Error> { let id_param = id.to_string(); let id: Result, sqlx::Error> = sqlx::query!( "UPDATE inventory_items AS item SET name = ?, weight = ? WHERE item.id = ? RETURNING inventory_items.category_id AS id ", name, weight, id_param, ) .fetch_one(pool) .map_ok(|row| { let id: &str = &row.id.unwrap(); // TODO let uuid: Result = Uuid::try_parse(id); let uuid: Result = uuid.map_err(|e| e.into()); uuid }) .await; match id { Err(e) => match e { sqlx::Error::RowNotFound => Ok(None), _ => Err(e.into()), }, Ok(v) => Ok(Some(v?)), } } pub async fn get_category_max_weight( pool: &sqlx::Pool, category_id: Uuid, ) -> Result { let category_id_param = category_id.to_string(); let weight: Result = sqlx::query!( " SELECT COALESCE(MAX(i_item.weight), 0) as weight FROM inventory_items_categories as category INNER JOIN inventory_items as i_item ON i_item.category_id = category.id WHERE category_id = ? ", category_id_param ) .fetch_one(pool) .map_ok(|row| { // convert to i64 because that the default integer type, but looks // like COALESCE return i32? // // We can be certain that the row exists, as we COALESCE it row.weight.unwrap() as i64 }) .await; Ok(weight?) } pub async fn _get_category_total_picked_weight( pool: &sqlx::Pool, category_id: Uuid, ) -> Result { let category_id_param = category_id.to_string(); let weight: Result = sqlx::query!( " SELECT COALESCE(SUM(i_item.weight), 0) as weight FROM inventory_items_categories as category INNER JOIN inventory_items as i_item ON i_item.category_id = category.id INNER JOIN trips_items as t_item ON i_item.id = t_item.item_id WHERE category_id = ? AND t_item.pick = 1 ", category_id_param ) .fetch_one(pool) .map_ok(|row| { // convert to i64 because that the default integer type, but looks // like COALESCE return i32? // // We can be certain that the row exists, as we COALESCE it row.weight.unwrap() as i64 }) .await; Ok(weight?) } } pub struct DbTripsTypesRow { pub id: String, pub name: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub enum TripTypeAttribute { #[serde(rename = "name")] Name, } pub struct TripsType { pub id: Uuid, pub name: String, } impl TryFrom for TripsType { type Error = Error; fn try_from(row: DbTripsTypesRow) -> Result { Ok(TripsType { id: Uuid::try_parse(&row.id)?, name: row.name, }) } } pub struct Product { pub id: Uuid, pub name: String, pub description: Option, pub comment: Option, } pub struct InventoryItem { pub id: Uuid, pub name: String, pub description: Option, pub weight: i64, pub category: Category, pub product: Option, } pub struct DbInventoryItemRow { pub id: String, pub name: String, pub description: Option, pub weight: i64, pub category_id: String, pub category_name: String, pub category_description: Option, pub product_id: Option, pub product_name: Option, pub product_description: Option, pub product_comment: Option, } impl TryFrom for InventoryItem { type Error = Error; fn try_from(row: DbInventoryItemRow) -> Result { Ok(InventoryItem { id: Uuid::try_parse(&row.id)?, name: row.name, description: row.description, weight: row.weight, category: Category { id: Uuid::try_parse(&row.category_id)?, name: row.category_name, description: row.category_description, items: None, }, product: row .product_id .map(|id| -> Result { Ok(Product { id: Uuid::try_parse(&id)?, name: row.product_name.unwrap(), description: row.product_description, comment: row.product_comment, }) }) .transpose()?, }) } } pub struct Inventory { pub categories: Vec, } impl Inventory { pub async fn load(pool: &sqlx::Pool) -> Result { let mut categories = sqlx::query_as!( DbCategoryRow, "SELECT id,name,description FROM inventory_items_categories" ) .fetch(pool) .map_ok(|row: DbCategoryRow| 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| Error::Sql { description: e.to_string(), })? .into_iter() .collect::, Error>>() // and this one is the model mapping error that may arise e.g. during // reading of the rows .map_err(|e| Error::Sql { description: e.to_string(), })?; for category in &mut categories { category.populate_items(pool).await?; } Ok(Self { categories }) } }