chore: refactored db.rs b334a4fd
Steve · 2026-04-15 19:19 4 file(s) · +154 −132
Cargo.lock +11 −0
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
apps/cellar/Cargo.toml +1 −0
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"
apps/cellar/src/db.rs +75 −80
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");
apps/cellar/src/server/handlers/admin.rs +67 −52
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()