chore: refactored db.rs
b334a4fd
4 file(s) · +154 −132
| 693 | 693 | "rust-embed", |
|
| 694 | 694 | "serde", |
|
| 695 | 695 | "serde_json", |
|
| 696 | + | "serde_rusqlite", |
|
| 696 | 697 | "subtle", |
|
| 697 | 698 | "tokio", |
|
| 698 | 699 | "tracing", |
|
| 4031 | 4032 | dependencies = [ |
|
| 4032 | 4033 | "itoa", |
|
| 4033 | 4034 | "serde", |
|
| 4035 | + | "serde_core", |
|
| 4036 | + | ] |
|
| 4037 | + | ||
| 4038 | + | [[package]] |
|
| 4039 | + | name = "serde_rusqlite" |
|
| 4040 | + | version = "0.41.1" |
|
| 4041 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4042 | + | checksum = "5224145f7ea2188165a3ad4e856c4452474683cd7a72dab3325201f673a2b410" |
|
| 4043 | + | dependencies = [ |
|
| 4044 | + | "rusqlite", |
|
| 4034 | 4045 | "serde_core", |
|
| 4035 | 4046 | ] |
|
| 4036 | 4047 | ||
| 26 | 26 | reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } |
|
| 27 | 27 | base64 = "0.22" |
|
| 28 | 28 | image = "0.25" |
|
| 29 | + | serde_rusqlite = "0.41" |
| 99 | 99 | } |
|
| 100 | 100 | ||
| 101 | 101 | fn wine_from_row(row: &rusqlite::Row) -> rusqlite::Result<Wine> { |
|
| 102 | - | Ok(Wine { |
|
| 103 | - | id: row.get(0)?, |
|
| 104 | - | short_id: row.get(1)?, |
|
| 105 | - | name: row.get(2)?, |
|
| 106 | - | origin: row.get(3)?, |
|
| 107 | - | grape: row.get(4)?, |
|
| 108 | - | notes: row.get(5)?, |
|
| 109 | - | has_image: row.get(6)?, |
|
| 110 | - | image_mime: row.get(7)?, |
|
| 111 | - | sweetness: row.get(8)?, |
|
| 112 | - | acidity: row.get(9)?, |
|
| 113 | - | tannin: row.get(10)?, |
|
| 114 | - | alcohol: row.get(11)?, |
|
| 115 | - | body: row.get(12)?, |
|
| 116 | - | clarity: row.get(13)?, |
|
| 117 | - | color_intensity: row.get(14)?, |
|
| 118 | - | aroma_intensity: row.get(15)?, |
|
| 119 | - | nose_complexity: row.get(16)?, |
|
| 120 | - | background: row.get(17)?, |
|
| 121 | - | created_at: row.get(18)?, |
|
| 122 | - | wishlist: row.get::<_, i32>(19)? != 0, |
|
| 102 | + | serde_rusqlite::from_row::<Wine>(row).map_err(|e| { |
|
| 103 | + | rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Null, Box::new(e)) |
|
| 123 | 104 | }) |
|
| 124 | 105 | } |
|
| 125 | 106 | ||
| 126 | 107 | const WINE_COLUMNS: &str = |
|
| 127 | 108 | "id, short_id, name, origin, grape, notes, (image IS NOT NULL) AS has_image, image_mime, sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background, created_at, wishlist"; |
|
| 128 | 109 | ||
| 129 | - | pub fn create_wine( |
|
| 130 | - | db: &Db, |
|
| 131 | - | name: &str, |
|
| 132 | - | origin: &str, |
|
| 133 | - | grape: &str, |
|
| 134 | - | notes: &str, |
|
| 135 | - | image: Option<&[u8]>, |
|
| 136 | - | image_mime: Option<&str>, |
|
| 137 | - | sweetness: i32, |
|
| 138 | - | acidity: i32, |
|
| 139 | - | tannin: i32, |
|
| 140 | - | alcohol: i32, |
|
| 141 | - | body: i32, |
|
| 142 | - | clarity: i32, |
|
| 143 | - | color_intensity: i32, |
|
| 144 | - | aroma_intensity: i32, |
|
| 145 | - | nose_complexity: i32, |
|
| 146 | - | background: &str, |
|
| 147 | - | wishlist: bool, |
|
| 148 | - | ) -> Result<Wine, DbError> { |
|
| 110 | + | #[derive(Serialize)] |
|
| 111 | + | pub struct WineInput<'a> { |
|
| 112 | + | pub name: &'a str, |
|
| 113 | + | pub origin: &'a str, |
|
| 114 | + | pub grape: &'a str, |
|
| 115 | + | pub notes: &'a str, |
|
| 116 | + | pub sweetness: i32, |
|
| 117 | + | pub acidity: i32, |
|
| 118 | + | pub tannin: i32, |
|
| 119 | + | pub alcohol: i32, |
|
| 120 | + | pub body: i32, |
|
| 121 | + | pub clarity: i32, |
|
| 122 | + | pub color_intensity: i32, |
|
| 123 | + | pub aroma_intensity: i32, |
|
| 124 | + | pub nose_complexity: i32, |
|
| 125 | + | pub background: &'a str, |
|
| 126 | + | } |
|
| 127 | + | ||
| 128 | + | pub fn create_wine(db: &Db, input: &WineInput, wishlist: bool) -> Result<Wine, DbError> { |
|
| 149 | 129 | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 150 | 130 | let short_id = nanoid!(10); |
|
| 151 | - | let wishlist_int: i32 = if wishlist { 1 } else { 0 }; |
|
| 131 | + | let named = serde_rusqlite::to_params_named(input) |
|
| 132 | + | .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; |
|
| 133 | + | let mut bindings = named.to_slice(); |
|
| 134 | + | bindings.push((":short_id", &short_id)); |
|
| 135 | + | bindings.push((":wishlist", &wishlist)); |
|
| 152 | 136 | conn.execute( |
|
| 153 | - | "INSERT INTO wines (short_id, name, origin, grape, notes, image, image_mime, sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background, wishlist) |
|
| 154 | - | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", |
|
| 155 | - | params![short_id, name, origin, grape, notes, image, image_mime, sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background, wishlist_int], |
|
| 137 | + | "INSERT INTO wines (short_id, name, origin, grape, notes, sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background, wishlist) |
|
| 138 | + | VALUES (:short_id, :name, :origin, :grape, :notes, :sweetness, :acidity, :tannin, :alcohol, :body, :clarity, :color_intensity, :aroma_intensity, :nose_complexity, :background, :wishlist)", |
|
| 139 | + | bindings.as_slice(), |
|
| 156 | 140 | )?; |
|
| 157 | 141 | let id = conn.last_insert_rowid(); |
|
| 158 | 142 | let wine = conn.query_row( |
|
| 263 | 247 | pub fn update_wine( |
|
| 264 | 248 | db: &Db, |
|
| 265 | 249 | short_id: &str, |
|
| 266 | - | name: &str, |
|
| 267 | - | origin: &str, |
|
| 268 | - | grape: &str, |
|
| 269 | - | notes: &str, |
|
| 270 | - | sweetness: i32, |
|
| 271 | - | acidity: i32, |
|
| 272 | - | tannin: i32, |
|
| 273 | - | alcohol: i32, |
|
| 274 | - | body: i32, |
|
| 275 | - | clarity: i32, |
|
| 276 | - | color_intensity: i32, |
|
| 277 | - | aroma_intensity: i32, |
|
| 278 | - | nose_complexity: i32, |
|
| 279 | - | background: &str, |
|
| 250 | + | input: &WineInput, |
|
| 280 | 251 | ) -> Result<Option<Wine>, DbError> { |
|
| 281 | 252 | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 253 | + | let named = serde_rusqlite::to_params_named(input) |
|
| 254 | + | .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; |
|
| 255 | + | let mut bindings = named.to_slice(); |
|
| 256 | + | bindings.push((":short_id", &short_id)); |
|
| 282 | 257 | let rows = conn.execute( |
|
| 283 | - | "UPDATE wines SET name = ?1, origin = ?2, grape = ?3, notes = ?4, sweetness = ?5, acidity = ?6, tannin = ?7, alcohol = ?8, body = ?9, clarity = ?10, color_intensity = ?11, aroma_intensity = ?12, nose_complexity = ?13, background = ?14 WHERE short_id = ?15", |
|
| 284 | - | params![name, origin, grape, notes, sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background, short_id], |
|
| 258 | + | "UPDATE wines SET name = :name, origin = :origin, grape = :grape, notes = :notes, sweetness = :sweetness, acidity = :acidity, tannin = :tannin, alcohol = :alcohol, body = :body, clarity = :clarity, color_intensity = :color_intensity, aroma_intensity = :aroma_intensity, nose_complexity = :nose_complexity, background = :background WHERE short_id = :short_id", |
|
| 259 | + | bindings.as_slice(), |
|
| 285 | 260 | )?; |
|
| 286 | 261 | if rows == 0 { |
|
| 287 | 262 | return Ok(None); |
|
| 401 | 376 | Arc::new(Mutex::new(conn)) |
|
| 402 | 377 | } |
|
| 403 | 378 | ||
| 379 | + | fn sample_input<'a>(name: &'a str, sweetness: i32) -> WineInput<'a> { |
|
| 380 | + | WineInput { |
|
| 381 | + | name, |
|
| 382 | + | origin: "France", |
|
| 383 | + | grape: "Merlot", |
|
| 384 | + | notes: "Smooth", |
|
| 385 | + | sweetness, |
|
| 386 | + | acidity: 3, |
|
| 387 | + | tannin: 3, |
|
| 388 | + | alcohol: 3, |
|
| 389 | + | body: 3, |
|
| 390 | + | clarity: 3, |
|
| 391 | + | color_intensity: 3, |
|
| 392 | + | aroma_intensity: 3, |
|
| 393 | + | nose_complexity: 3, |
|
| 394 | + | background: "", |
|
| 395 | + | } |
|
| 396 | + | } |
|
| 397 | + | ||
| 404 | 398 | fn create_test_wine(db: &Db, name: &str, wishlist: bool) -> Wine { |
|
| 405 | - | create_wine( |
|
| 406 | - | db, name, "France", "Merlot", "Smooth", None, None, |
|
| 407 | - | 3, 3, 3, 3, 3, 3, 3, 3, 3, "", wishlist, |
|
| 408 | - | ) |
|
| 409 | - | .unwrap() |
|
| 399 | + | create_wine(db, &sample_input(name, 3), wishlist).unwrap() |
|
| 410 | 400 | } |
|
| 411 | 401 | ||
| 412 | 402 | // ── Wine CRUD ────────────────────────────────────────────────────── |
|
| 426 | 416 | #[test] |
|
| 427 | 417 | fn create_wine_invalid_sweetness_fails() { |
|
| 428 | 418 | let db = test_db(); |
|
| 429 | - | let result = create_wine( |
|
| 430 | - | &db, "Bad", "X", "X", "X", None, None, |
|
| 431 | - | 6, 3, 3, 3, 3, 3, 3, 3, 3, "", false, // sweetness=6 > 5 |
|
| 432 | - | ); |
|
| 419 | + | let result = create_wine(&db, &sample_input("Bad", 6), false); |
|
| 433 | 420 | assert!(result.is_err()); |
|
| 434 | 421 | } |
|
| 435 | 422 | ||
| 436 | 423 | #[test] |
|
| 437 | 424 | fn create_wine_zero_rating_fails() { |
|
| 438 | 425 | let db = test_db(); |
|
| 439 | - | let result = create_wine( |
|
| 440 | - | &db, "Bad", "X", "X", "X", None, None, |
|
| 441 | - | 0, 3, 3, 3, 3, 3, 3, 3, 3, "", false, // sweetness=0 < 1 |
|
| 442 | - | ); |
|
| 426 | + | let result = create_wine(&db, &sample_input("Bad", 0), false); |
|
| 443 | 427 | assert!(result.is_err()); |
|
| 444 | 428 | } |
|
| 445 | 429 | ||
| 491 | 475 | let db = test_db(); |
|
| 492 | 476 | let wine = create_test_wine(&db, "Old Name", false); |
|
| 493 | 477 | ||
| 494 | - | let updated = update_wine( |
|
| 495 | - | &db, &wine.short_id, "New Name", "Italy", "Sangiovese", "Bold", |
|
| 496 | - | 4, 4, 4, 4, 4, 4, 4, 4, 4, "deep red", |
|
| 497 | - | ) |
|
| 498 | - | .unwrap() |
|
| 499 | - | .unwrap(); |
|
| 478 | + | let input = WineInput { |
|
| 479 | + | name: "New Name", |
|
| 480 | + | origin: "Italy", |
|
| 481 | + | grape: "Sangiovese", |
|
| 482 | + | notes: "Bold", |
|
| 483 | + | sweetness: 4, |
|
| 484 | + | acidity: 4, |
|
| 485 | + | tannin: 4, |
|
| 486 | + | alcohol: 4, |
|
| 487 | + | body: 4, |
|
| 488 | + | clarity: 4, |
|
| 489 | + | color_intensity: 4, |
|
| 490 | + | aroma_intensity: 4, |
|
| 491 | + | nose_complexity: 4, |
|
| 492 | + | background: "deep red", |
|
| 493 | + | }; |
|
| 494 | + | let updated = update_wine(&db, &wine.short_id, &input).unwrap().unwrap(); |
|
| 500 | 495 | ||
| 501 | 496 | assert_eq!(updated.name, "New Name"); |
|
| 502 | 497 | assert_eq!(updated.origin, "Italy"); |
|
| 136 | 136 | } |
|
| 137 | 137 | }; |
|
| 138 | 138 | ||
| 139 | - | match db::create_wine( |
|
| 140 | - | &state.db, |
|
| 141 | - | &data.name, |
|
| 142 | - | &data.origin, |
|
| 143 | - | &data.grape, |
|
| 144 | - | &data.notes, |
|
| 145 | - | data.image.as_deref(), |
|
| 146 | - | data.image_mime.as_deref(), |
|
| 147 | - | data.sweetness, |
|
| 148 | - | data.acidity, |
|
| 149 | - | data.tannin, |
|
| 150 | - | data.alcohol, |
|
| 151 | - | data.body, |
|
| 152 | - | data.clarity, |
|
| 153 | - | data.color_intensity, |
|
| 154 | - | data.aroma_intensity, |
|
| 155 | - | data.nose_complexity, |
|
| 156 | - | &data.background, |
|
| 157 | - | false, |
|
| 158 | - | ) { |
|
| 159 | - | Ok(wine) => Redirect::to(&format!("/wines/{}", wine.short_id)).into_response(), |
|
| 139 | + | let input = db::WineInput { |
|
| 140 | + | name: &data.name, |
|
| 141 | + | origin: &data.origin, |
|
| 142 | + | grape: &data.grape, |
|
| 143 | + | notes: &data.notes, |
|
| 144 | + | sweetness: data.sweetness, |
|
| 145 | + | acidity: data.acidity, |
|
| 146 | + | tannin: data.tannin, |
|
| 147 | + | alcohol: data.alcohol, |
|
| 148 | + | body: data.body, |
|
| 149 | + | clarity: data.clarity, |
|
| 150 | + | color_intensity: data.color_intensity, |
|
| 151 | + | aroma_intensity: data.aroma_intensity, |
|
| 152 | + | nose_complexity: data.nose_complexity, |
|
| 153 | + | background: &data.background, |
|
| 154 | + | }; |
|
| 155 | + | match db::create_wine(&state.db, &input, false) { |
|
| 156 | + | Ok(wine) => { |
|
| 157 | + | if let (Some(image), Some(mime)) = (&data.image, &data.image_mime) { |
|
| 158 | + | if let Err(e) = db::update_wine_image(&state.db, &wine.short_id, image, mime) { |
|
| 159 | + | tracing::error!("Failed to set wine image: {}", e); |
|
| 160 | + | } |
|
| 161 | + | } |
|
| 162 | + | Redirect::to(&format!("/wines/{}", wine.short_id)).into_response() |
|
| 163 | + | } |
|
| 160 | 164 | Err(e) => { |
|
| 161 | 165 | tracing::error!("Failed to create wine: {}", e); |
|
| 162 | 166 | Redirect::to("/admin/new?error=Failed+to+create+wine").into_response() |
|
| 182 | 186 | } |
|
| 183 | 187 | }; |
|
| 184 | 188 | ||
| 185 | - | match db::update_wine( |
|
| 186 | - | &state.db, |
|
| 187 | - | &short_id, |
|
| 188 | - | &data.name, |
|
| 189 | - | &data.origin, |
|
| 190 | - | &data.grape, |
|
| 191 | - | &data.notes, |
|
| 192 | - | data.sweetness, |
|
| 193 | - | data.acidity, |
|
| 194 | - | data.tannin, |
|
| 195 | - | data.alcohol, |
|
| 196 | - | data.body, |
|
| 197 | - | data.clarity, |
|
| 198 | - | data.color_intensity, |
|
| 199 | - | data.aroma_intensity, |
|
| 200 | - | data.nose_complexity, |
|
| 201 | - | &data.background, |
|
| 202 | - | ) { |
|
| 189 | + | let input = db::WineInput { |
|
| 190 | + | name: &data.name, |
|
| 191 | + | origin: &data.origin, |
|
| 192 | + | grape: &data.grape, |
|
| 193 | + | notes: &data.notes, |
|
| 194 | + | sweetness: data.sweetness, |
|
| 195 | + | acidity: data.acidity, |
|
| 196 | + | tannin: data.tannin, |
|
| 197 | + | alcohol: data.alcohol, |
|
| 198 | + | body: data.body, |
|
| 199 | + | clarity: data.clarity, |
|
| 200 | + | color_intensity: data.color_intensity, |
|
| 201 | + | aroma_intensity: data.aroma_intensity, |
|
| 202 | + | nose_complexity: data.nose_complexity, |
|
| 203 | + | background: &data.background, |
|
| 204 | + | }; |
|
| 205 | + | match db::update_wine(&state.db, &short_id, &input) { |
|
| 203 | 206 | Ok(Some(_)) => { |
|
| 204 | 207 | if let Some(image) = &data.image { |
|
| 205 | 208 | if let Some(mime) = &data.image_mime { |
|
| 285 | 288 | } |
|
| 286 | 289 | }; |
|
| 287 | 290 | ||
| 288 | - | match db::create_wine( |
|
| 289 | - | &state.db, |
|
| 290 | - | &data.name, |
|
| 291 | - | &data.origin, |
|
| 292 | - | &data.grape, |
|
| 293 | - | &data.notes, |
|
| 294 | - | data.image.as_deref(), |
|
| 295 | - | data.image_mime.as_deref(), |
|
| 296 | - | 3, 3, 3, 3, 3, 3, 3, 3, 3, |
|
| 297 | - | &data.background, |
|
| 298 | - | true, |
|
| 299 | - | ) { |
|
| 300 | - | Ok(_) => Redirect::to("/wishlist").into_response(), |
|
| 291 | + | let input = db::WineInput { |
|
| 292 | + | name: &data.name, |
|
| 293 | + | origin: &data.origin, |
|
| 294 | + | grape: &data.grape, |
|
| 295 | + | notes: &data.notes, |
|
| 296 | + | sweetness: 3, |
|
| 297 | + | acidity: 3, |
|
| 298 | + | tannin: 3, |
|
| 299 | + | alcohol: 3, |
|
| 300 | + | body: 3, |
|
| 301 | + | clarity: 3, |
|
| 302 | + | color_intensity: 3, |
|
| 303 | + | aroma_intensity: 3, |
|
| 304 | + | nose_complexity: 3, |
|
| 305 | + | background: &data.background, |
|
| 306 | + | }; |
|
| 307 | + | match db::create_wine(&state.db, &input, true) { |
|
| 308 | + | Ok(wine) => { |
|
| 309 | + | if let (Some(image), Some(mime)) = (&data.image, &data.image_mime) { |
|
| 310 | + | if let Err(e) = db::update_wine_image(&state.db, &wine.short_id, image, mime) { |
|
| 311 | + | tracing::error!("Failed to set wine image: {}", e); |
|
| 312 | + | } |
|
| 313 | + | } |
|
| 314 | + | Redirect::to("/wishlist").into_response() |
|
| 315 | + | } |
|
| 301 | 316 | Err(e) => { |
|
| 302 | 317 | tracing::error!("Failed to create wishlist wine: {}", e); |
|
| 303 | 318 | Redirect::to("/admin/wishlist/new?error=Failed+to+create+wine").into_response() |
|