//! Ownership operations: what cards the user has and how many. use std::collections::HashMap; use rusqlite::{params, OptionalExtension}; use crate::db::REF_DB; use crate::error::Result; use crate::model::{CardRef, ColorSet, LibraryCard}; use crate::Catalog; /// Map a row shaped `(oracle_id, quantity_owned, location, name, mana_value, /// colors, color_identity, type_line, image_uri, back_image_uri)` into its /// library parts. Shared by [`Catalog::list_library`] and the criteria search. pub(crate) fn row_to_library_parts( r: &rusqlite::Row, ) -> rusqlite::Result<(String, i64, String, CardRef)> { let oracle_id: String = r.get(0)?; Ok(( oracle_id.clone(), r.get::<_, i64>(1)?, r.get::<_, String>(2)?, CardRef { oracle_id, name: r.get(3)?, mana_value: r.get(4)?, colors: ColorSet(r.get::<_, i64>(5)? as u8), color_identity: ColorSet(r.get::<_, i64>(6)? as u8), type_line: r.get(7)?, image_uri: r.get::<_, Option>(8)?.unwrap_or_default(), back_image_uri: r.get::<_, Option>(9)?.unwrap_or_default(), }, )) } impl Catalog { /// Add `delta` copies of a card to the library (increment, creating the row /// if needed). `delta` may be negative to decrement; the quantity floors at 0. /// Returns the new owned quantity. pub fn add_owned(&self, oracle_id: &str, delta: i64) -> Result { let current: i64 = self .conn .query_row( "SELECT quantity_owned FROM library_cards WHERE oracle_id = ?1", params![oracle_id], |r| r.get(0), ) .optional()? .unwrap_or(0); self.set_owned(oracle_id, current + delta) } /// Set the absolute owned quantity for a card (0 removes ownership but keeps /// any deck assignments referencing it). pub fn set_owned(&self, oracle_id: &str, quantity: i64) -> Result { let q = quantity.max(0); self.conn.execute( "INSERT INTO library_cards(oracle_id, quantity_owned) VALUES (?1, ?2) ON CONFLICT(oracle_id) DO UPDATE SET quantity_owned = excluded.quantity_owned", params![oracle_id, q], )?; Ok(q) } /// List every owned card (quantity_owned > 0) with deck context, ordered by /// name. This is the "browse my library" view without extra criteria. pub fn list_library(&self) -> Result> { let deck_names = self.deck_names_by_oracle()?; let assigned = self.assigned_totals_by_oracle()?; let mut stmt = self.conn.prepare(&format!( "SELECT lc.oracle_id, lc.quantity_owned, lc.location, oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}, {back} FROM library_cards lc JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id WHERE lc.quantity_owned > 0 ORDER BY oc.name COLLATE NOCASE", image = crate::db::image_subquery("image_uri"), back = crate::db::image_subquery("back_image_uri") ))?; let rows = stmt .query_map([], row_to_library_parts)? .collect::, _>>()?; Ok(rows .into_iter() .map(|(oid, qty, location, card)| LibraryCard { quantity_owned: qty, decks: deck_names.get(&oid).cloned().unwrap_or_default(), quantity_assigned: *assigned.get(&oid).unwrap_or(&0), location, card, }) .collect()) } /// Set the free-text storage location for a card (creates the library row if /// absent). Empty string clears it. pub fn set_location(&self, oracle_id: &str, location: &str) -> Result<()> { self.conn.execute( "INSERT INTO library_cards(oracle_id, location) VALUES (?1, ?2) ON CONFLICT(oracle_id) DO UPDATE SET location = excluded.location", params![oracle_id, location], )?; Ok(()) } /// Map each owned/assigned oracle_id to the deck names it appears in. pub(crate) fn deck_names_by_oracle(&self) -> Result>> { let mut stmt = self.conn.prepare( "SELECT dc.oracle_id, d.name FROM deck_cards dc JOIN decks d ON d.id = dc.deck_id ORDER BY d.name COLLATE NOCASE", )?; let mut map: HashMap> = HashMap::new(); let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?; for row in rows { let (oid, name) = row?; map.entry(oid).or_default().push(name); } Ok(map) } pub(crate) fn assigned_totals_by_oracle(&self) -> Result> { let mut stmt = self .conn .prepare("SELECT oracle_id, SUM(quantity) FROM deck_cards GROUP BY oracle_id")?; let mut map = HashMap::new(); let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?; for row in rows { let (oid, total) = row?; map.insert(oid, total); } Ok(map) } } #[cfg(test)] mod tests { use crate::test_support; #[test] fn add_increment_and_floor() { let fx = test_support::make(); let cat = &fx.catalog; assert_eq!(cat.add_owned("sol-ring", 1).unwrap(), 1); assert_eq!(cat.add_owned("sol-ring", 2).unwrap(), 3); assert_eq!(cat.add_owned("sol-ring", -10).unwrap(), 0); // floors at 0 assert_eq!(cat.set_owned("sol-ring", 4).unwrap(), 4); let lib = cat.list_library().unwrap(); let sol = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap(); assert_eq!(sol.quantity_owned, 4); assert!(sol.decks.is_empty()); // Image URL is plumbed through from the printings table. assert_eq!(sol.card.image_uri, "http://img/sol-ring"); } #[test] fn back_image_uri_is_plumbed_through() { let fx = test_support::make(); let cat = &fx.catalog; cat.connection() .execute( "UPDATE ref.printings SET back_image_uri = 'http://img/back' \ WHERE oracle_id = 'sol-ring'", [], ) .unwrap(); cat.add_owned("sol-ring", 1).unwrap(); cat.add_owned("llanowar-elves", 1).unwrap(); let lib = cat.list_library().unwrap(); let sol = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap(); assert_eq!(sol.card.back_image_uri, "http://img/back"); // Single-faced cards have no back image. let elves = lib.iter().find(|c| c.card.oracle_id == "llanowar-elves").unwrap(); assert_eq!(elves.card.back_image_uri, ""); } #[test] fn location_is_stored_and_listed() { let fx = test_support::make(); let cat = &fx.catalog; cat.add_owned("sol-ring", 1).unwrap(); cat.set_location("sol-ring", "Box 1").unwrap(); let lib = cat.list_library().unwrap(); let sol = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap(); assert_eq!(sol.location, "Box 1"); // Clearing works. cat.set_location("sol-ring", "").unwrap(); let lib = cat.list_library().unwrap(); let sol = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap(); assert_eq!(sol.location, ""); } #[test] fn list_is_ordered_and_excludes_zero() { let fx = test_support::make(); let cat = &fx.catalog; cat.add_owned("serra-angel", 1).unwrap(); cat.add_owned("lightning-bolt", 1).unwrap(); cat.set_owned("sol-ring", 0).unwrap(); let names: Vec<_> = cat .list_library() .unwrap() .into_iter() .map(|c| c.card.name) .collect(); assert_eq!(names, vec!["Lightning Bolt", "Serra Angel"]); } }