//! Deck operations and deck membership lookups. use rusqlite::{params, OptionalExtension}; use crate::db::REF_DB; use crate::error::Result; use crate::model::{CardRef, ColorSet, Deck, DeckEntry}; use crate::Catalog; impl Catalog { /// Create a deck. `commander_oracle_id` is optional. pub fn create_deck(&self, name: &str, commander_oracle_id: Option<&str>) -> Result { self.conn.execute( "INSERT INTO decks(name, commander_oracle_id) VALUES (?1, ?2)", params![name, commander_oracle_id], )?; let id = self.conn.last_insert_rowid(); self.get_deck(id) .map(|d| d.expect("just-inserted deck must exist")) } pub fn get_deck(&self, id: i64) -> Result> { Ok(self .conn .query_row( "SELECT id, name, format, commander_oracle_id, notes FROM decks WHERE id = ?1", params![id], Self::map_deck, ) .optional()?) } pub fn list_decks(&self) -> Result> { let mut stmt = self.conn.prepare( "SELECT id, name, format, commander_oracle_id, notes FROM decks ORDER BY name COLLATE NOCASE", )?; let rows = stmt .query_map([], Self::map_deck)? .collect::, _>>()?; Ok(rows) } pub fn delete_deck(&self, id: i64) -> Result<()> { // ON DELETE CASCADE clears deck_cards. self.conn .execute("DELETE FROM decks WHERE id = ?1", params![id])?; Ok(()) } /// Set how many copies of a card belong to a deck. `quantity <= 0` removes /// the card from the deck. Upserts on (deck_id, oracle_id). pub fn set_deck_card( &self, deck_id: i64, oracle_id: &str, quantity: i64, is_commander: bool, ) -> Result<()> { if quantity <= 0 { self.conn.execute( "DELETE FROM deck_cards WHERE deck_id = ?1 AND oracle_id = ?2", params![deck_id, oracle_id], )?; return Ok(()); } self.conn.execute( "INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(deck_id, oracle_id) DO UPDATE SET quantity = excluded.quantity, is_commander = excluded.is_commander", params![deck_id, oracle_id, quantity, is_commander as i64], )?; Ok(()) } /// All cards in a deck — owned or not — with their in-deck quantity and how /// many copies are owned (0 if the card isn't in the library). Callers split /// this into "in your library" vs "not owned" groups. pub fn deck_contents(&self, deck_id: i64) -> Result> { let mut stmt = self.conn.prepare(&format!( "SELECT dc.quantity, dc.is_commander, COALESCE(lc.quantity_owned, 0), oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}, {back} FROM deck_cards dc JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = dc.oracle_id LEFT JOIN library_cards lc ON lc.oracle_id = dc.oracle_id WHERE dc.deck_id = ?1 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(params![deck_id], |r| { Ok(DeckEntry { quantity: r.get(0)?, is_commander: r.get::<_, i64>(1)? != 0, quantity_owned: r.get(2)?, card: CardRef { oracle_id: r.get(3)?, name: r.get(4)?, mana_value: r.get(5)?, colors: ColorSet(r.get::<_, i64>(6)? as u8), color_identity: ColorSet(r.get::<_, i64>(7)? as u8), type_line: r.get(8)?, image_uri: r.get::<_, Option>(9)?.unwrap_or_default(), back_image_uri: r.get::<_, Option>(10)?.unwrap_or_default(), }, }) })? .collect::, _>>()?; Ok(rows) } /// The decks a given card is currently assigned to. pub fn card_decks(&self, oracle_id: &str) -> Result> { let mut stmt = self.conn.prepare( "SELECT d.id, d.name, d.format, d.commander_oracle_id, d.notes FROM deck_cards dc JOIN decks d ON d.id = dc.deck_id WHERE dc.oracle_id = ?1 ORDER BY d.name COLLATE NOCASE", )?; let rows = stmt .query_map(params![oracle_id], Self::map_deck)? .collect::, _>>()?; Ok(rows) } fn map_deck(r: &rusqlite::Row) -> rusqlite::Result { Ok(Deck { id: r.get(0)?, name: r.get(1)?, format: r.get(2)?, commander_oracle_id: r.get(3)?, notes: r.get(4)?, }) } /// Set the deck's format (e.g. "Commander"). Empty string clears it. pub fn set_deck_format(&self, deck_id: i64, format: &str) -> Result<()> { self.conn.execute( "UPDATE decks SET format = ?1 WHERE id = ?2", params![format, deck_id], )?; Ok(()) } /// Set (or clear, with `None`) the deck's commander card. Also marks/unmarks /// the corresponding deck_cards row as the commander when present. pub fn set_deck_commander(&self, deck_id: i64, oracle_id: Option<&str>) -> Result<()> { self.conn.execute( "UPDATE decks SET commander_oracle_id = ?1 WHERE id = ?2", params![oracle_id, deck_id], )?; // Reset any existing commander flag, then set it on the chosen card. self.conn.execute( "UPDATE deck_cards SET is_commander = 0 WHERE deck_id = ?1", params![deck_id], )?; if let Some(oid) = oracle_id { self.conn.execute( "UPDATE deck_cards SET is_commander = 1 WHERE deck_id = ?1 AND oracle_id = ?2", params![deck_id, oid], )?; } Ok(()) } } #[cfg(test)] mod tests { use crate::test_support; #[test] fn deck_membership_visible_from_library() { let fx = test_support::make(); let cat = &fx.catalog; cat.add_owned("llanowar-elves", 4).unwrap(); let elves = cat.create_deck("Elfball", Some("llanowar-elves")).unwrap(); cat.set_deck_card(elves.id, "llanowar-elves", 4, false) .unwrap(); // card_decks reports membership. let decks = cat.card_decks("llanowar-elves").unwrap(); assert_eq!(decks.len(), 1); assert_eq!(decks[0].name, "Elfball"); // list_library surfaces the deck name on the card row. let lib = cat.list_library().unwrap(); let row = lib .iter() .find(|c| c.card.oracle_id == "llanowar-elves") .unwrap(); assert_eq!(row.decks, vec!["Elfball".to_string()]); assert_eq!(row.quantity_assigned, 4); assert!(!row.is_over_assigned()); } #[test] fn over_assignment_detected_across_decks() { let fx = test_support::make(); let cat = &fx.catalog; cat.add_owned("sol-ring", 1).unwrap(); // own one physical copy let a = cat.create_deck("Deck A", None).unwrap(); let b = cat.create_deck("Deck B", None).unwrap(); cat.set_deck_card(a.id, "sol-ring", 1, false).unwrap(); cat.set_deck_card(b.id, "sol-ring", 1, false).unwrap(); let lib = cat.list_library().unwrap(); let sol = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap(); assert_eq!(sol.quantity_assigned, 2); assert_eq!(sol.quantity_owned, 1); assert!(sol.is_over_assigned()); assert_eq!(sol.decks.len(), 2); } #[test] fn format_and_commander_metadata() { let fx = test_support::make(); let cat = &fx.catalog; let d = cat.create_deck("EDH", None).unwrap(); assert_eq!(d.format, ""); cat.set_deck_format(d.id, "Commander").unwrap(); cat.set_deck_card(d.id, "llanowar-elves", 1, false).unwrap(); cat.set_deck_commander(d.id, Some("llanowar-elves")).unwrap(); let d = cat.get_deck(d.id).unwrap().unwrap(); assert_eq!(d.format, "Commander"); assert_eq!(d.commander_oracle_id.as_deref(), Some("llanowar-elves")); // The card is flagged as commander in the deck. let commander = cat .deck_contents(d.id) .unwrap() .into_iter() .find(|e| e.card.oracle_id == "llanowar-elves") .unwrap(); assert!(commander.is_commander); // Clearing works. cat.set_deck_commander(d.id, None).unwrap(); assert_eq!( cat.get_deck(d.id).unwrap().unwrap().commander_oracle_id, None ); } #[test] fn deck_contents_include_owned_and_unowned() { let fx = test_support::make(); let cat = &fx.catalog; cat.add_owned("llanowar-elves", 4).unwrap(); // owned // serra-angel intentionally NOT owned. let d = cat.create_deck("Mixed", None).unwrap(); cat.set_deck_card(d.id, "llanowar-elves", 4, false).unwrap(); cat.set_deck_card(d.id, "serra-angel", 1, true).unwrap(); let contents = cat.deck_contents(d.id).unwrap(); assert_eq!(contents.len(), 2); let owned: Vec<_> = contents.iter().filter(|e| e.quantity_owned > 0).collect(); let unowned: Vec<_> = contents.iter().filter(|e| e.quantity_owned == 0).collect(); assert_eq!(owned.len(), 1); assert_eq!(owned[0].card.name, "Llanowar Elves"); assert_eq!(owned[0].quantity, 4); assert_eq!(unowned.len(), 1); assert_eq!(unowned[0].card.name, "Serra Angel"); assert!(unowned[0].is_commander); } #[test] fn setting_zero_removes_and_cascade_on_delete() { let fx = test_support::make(); let cat = &fx.catalog; let d = cat.create_deck("Temp", None).unwrap(); cat.set_deck_card(d.id, "lightning-bolt", 2, false).unwrap(); cat.set_deck_card(d.id, "lightning-bolt", 0, false).unwrap(); assert!(cat.card_decks("lightning-bolt").unwrap().is_empty()); cat.set_deck_card(d.id, "lightning-bolt", 1, false).unwrap(); cat.delete_deck(d.id).unwrap(); assert!(cat.card_decks("lightning-bolt").unwrap().is_empty()); } }