//! 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, Legalities, LibraryCard, LibraryFacets, LibraryStats, WUBRG}; 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, produced_mana, /// produces_colorless, legalities, price_usd, horizontal)` into its library /// parts. Shared by [`Catalog::list_library`] and the criteria search. The /// caller's `SELECT` must put `horizontal` (or [`Catalog::horizontal_col`]'s /// `0` fallback) last, since it's not part of the base reference schema on /// every db (see `Catalog::has_horizontal_column`). 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(), produced_mana: ColorSet(r.get::<_, i64>(10)? as u8), produces_colorless: r.get::<_, i64>(11)? != 0, legalities: Legalities(r.get::<_, i64>(12)?), price_usd: r.get::<_, Option>(13)?.unwrap_or_default(), horizontal: r.get::<_, i64>(14)? != 0, }, )) } 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}, oc.produced_mana, oc.produces_colorless, oc.legalities, {price}, {horizontal} 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 = self.image_expr("image_uri"), back = self.image_expr("back_image_uri"), price = crate::db::price_subquery(), horizontal = self.horizontal_col() ))?; 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()) } /// Aggregate stats about the owned library, for the sidebar summary. pub fn library_stats(&self) -> Result { let unique_cards: i64 = self.conn.query_row( "SELECT COUNT(*) FROM library_cards WHERE quantity_owned > 0", [], |r| r.get(0), )?; let total_copies: i64 = self.conn.query_row( "SELECT COALESCE(SUM(quantity_owned), 0) FROM library_cards", [], |r| r.get(0), )?; let deck_count: i64 = self .conn .query_row("SELECT COUNT(*) FROM decks", [], |r| r.get(0))?; let mut color_counts = [0i64; 5]; let mut colorless_count = 0i64; { let mut stmt = self.conn.prepare(&format!( "SELECT oc.colors, lc.quantity_owned FROM library_cards lc JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id WHERE lc.quantity_owned > 0" ))?; let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)? as u8, r.get::<_, i64>(1)?)))?; for row in rows { let (colors, qty) = row?; if colors == 0 { colorless_count += qty; } else { for (i, (bit, _)) in WUBRG.iter().enumerate() { if colors & bit != 0 { color_counts[i] += qty; } } } } } Ok(LibraryStats { unique_cards, total_copies, deck_count, color_counts, colorless_count, }) } /// Distinct type/subtype/keyword values among owned cards, for filter /// autocomplete. pub fn library_facets(&self) -> Result { Ok(LibraryFacets { types: self.distinct_owned_facet("card_types", "type")?, subtypes: self.distinct_owned_facet("card_subtypes", "subtype")?, keywords: self.distinct_owned_facet("card_keywords", "keyword")?, }) } /// Distinct type/subtype/keyword values across the *whole* reference db /// (every downloaded card, owned or not) — the Card Search tab's /// equivalent of [`Catalog::library_facets`]. pub fn reference_facets(&self) -> Result { Ok(LibraryFacets { types: self.distinct_facet("card_types", "type")?, subtypes: self.distinct_facet("card_subtypes", "subtype")?, keywords: self.distinct_facet("card_keywords", "keyword")?, }) } /// `table`/`column` are always one of the three hardcoded call sites above /// (never user input), so interpolating them into the query is safe. fn distinct_owned_facet(&self, table: &str, column: &str) -> Result> { let mut stmt = self.conn.prepare(&format!( "SELECT DISTINCT x.{column} FROM {REF_DB}.{table} x WHERE x.oracle_id IN (SELECT oracle_id FROM library_cards WHERE quantity_owned > 0) ORDER BY x.{column} COLLATE NOCASE" ))?; let rows = stmt.query_map([], |r| r.get::<_, String>(0))?; Ok(rows.collect::, _>>()?) } /// Same as [`Catalog::distinct_owned_facet`] but unfiltered by ownership. fn distinct_facet(&self, table: &str, column: &str) -> Result> { let mut stmt = self.conn.prepare(&format!( "SELECT DISTINCT x.{column} FROM {REF_DB}.{table} x ORDER BY x.{column} COLLATE NOCASE" ))?; let rows = stmt.query_map([], |r| r.get::<_, String>(0))?; Ok(rows.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> { // Maybeboard cards aren't actually committed to a deck, so they // shouldn't count against a card's owned copies. let mut stmt = self.conn.prepare( "SELECT oracle_id, SUM(quantity) FROM deck_cards WHERE is_maybeboard = 0 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 library_facets_only_include_owned_cards() { let fx = test_support::make(); let cat = &fx.catalog; // Own llanowar-elves (Creature — Elf Druid) and lightning-bolt // (Instant); leave serra-angel (Creature — Angel, Flying/Vigilance) // unowned. cat.add_owned("llanowar-elves", 1).unwrap(); cat.add_owned("lightning-bolt", 1).unwrap(); let facets = cat.library_facets().unwrap(); assert_eq!(facets.types, vec!["Creature", "Instant"]); assert_eq!(facets.subtypes, vec!["Druid", "Elf"]); assert!(facets.keywords.is_empty()); // Flying/Vigilance only on the unowned Serra Angel } #[test] fn reference_facets_include_unowned_cards() { let fx = test_support::make(); let cat = &fx.catalog; // Nothing owned at all — reference_facets should still see everything // in the fixture, unlike library_facets. let facets = cat.reference_facets().unwrap(); assert_eq!(facets.types, vec!["Artifact", "Creature", "Instant"]); assert!(facets.keywords.contains(&"Flying".to_string())); assert!(facets.keywords.contains(&"Vigilance".to_string())); } #[test] fn library_stats_aggregates_counts_and_colors() { let fx = test_support::make(); let cat = &fx.catalog; cat.add_owned("llanowar-elves", 2).unwrap(); // G cat.add_owned("deathrite-shaman", 1).unwrap(); // BG cat.add_owned("sol-ring", 3).unwrap(); // colorless cat.create_deck("Deck A", None).unwrap(); cat.create_deck("Deck B", None).unwrap(); let stats = cat.library_stats().unwrap(); assert_eq!(stats.unique_cards, 3); assert_eq!(stats.total_copies, 6); assert_eq!(stats.deck_count, 2); assert_eq!(stats.colorless_count, 3); // WUBRG order: W, U, B, R, G. assert_eq!(stats.color_counts, [0, 0, 1, 0, 3]); } #[test] fn oracle_cards_image_wins_over_the_printings_pick() { // The bug this guards: `printings` can hold an arbitrary (promo, // showcase, foreign-language...) printing per card. `oracle_cards`'s // own image — Scryfall's representative-printing pick, captured at // ingest — must win when present, rather than whatever `printings` // subquery would otherwise resolve to (first by rowid). let fx = test_support::make(); let cat = &fx.catalog; cat.connection() .execute( "UPDATE ref.printings SET image_uri = 'http://img/promo-variant' \ WHERE oracle_id = 'sol-ring'", [], ) .unwrap(); cat.connection() .execute( "UPDATE ref.oracle_cards SET image_uri = 'http://img/normal' \ WHERE oracle_id = 'sol-ring'", [], ) .unwrap(); cat.add_owned("sol-ring", 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.image_uri, "http://img/normal"); } #[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 price_usd_is_plumbed_through() { let fx = test_support::make(); let cat = &fx.catalog; cat.connection() .execute( "UPDATE ref.printings SET price_usd = 12.34 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.price_usd, 12.34); // Fixture cards default to no known price. let elves = lib.iter().find(|c| c.card.oracle_id == "llanowar-elves").unwrap(); assert_eq!(elves.card.price_usd, 0.0); } /// Regression test: a card whose *displayed* printing (the one supplying /// `image_uri`) has no known price shouldn't show as priceless if a /// different printing does — e.g. a promo/showcase variant with no /// listed USD price, while the regular printing has one. #[test] fn price_uses_cheapest_available_printing_even_if_display_printing_has_none() { let fx = test_support::make(); let cat = &fx.catalog; // The fixture's only printing ("print-sol-ring") supplies the image // and has no price. Add a second, imageless printing with a price. cat.connection() .execute( "INSERT INTO ref.printings (scryfall_id, oracle_id, price_usd) VALUES ('print-sol-ring-promo', 'sol-ring', 5.67)", [], ) .unwrap(); cat.add_owned("sol-ring", 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.price_usd, 5.67); // Image still comes from the original (imaged) printing. assert_eq!(sol.card.image_uri, "http://img/sol-ring"); } #[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"]); } }