//! Per-printing detail layered over the oracle-level library: which printings //! a card has, which printing + finish owned copies are, and which printing's //! art a deck slot shows. //! //! `library_cards.quantity_owned` stays the authoritative total. Copy groups //! (`library_card_copies`) annotate a subset of it, and two rules keep them //! consistent with that total: //! //! - **Grow:** tracking more copies of a printing than are unspecified raises //! `quantity_owned` to fit — recording a copy means you own it. //! - **Clamp:** lowering `quantity_owned` below the tracked total trims the //! most recently added groups first ([`Catalog::clamp_copies`]). Lowering a //! group never lowers `quantity_owned`; those copies become unspecified. use rusqlite::{params, OptionalExtension}; use crate::db::REF_DB; use crate::error::{CoreError, Result}; use crate::model::{LibraryCopy, PrintingRef}; use crate::Catalog; /// The finishes a copy can be tracked as — Scryfall's `finishes` values. pub const FINISHES: [&str; 3] = ["nonfoil", "foil", "etched"]; /// Map the ten printing columns starting at `at` (`scryfall_id, set_code, /// collector_number, finishes, rarity, image_uri, back_image_uri, price_usd, /// lang, released_at`) into a [`PrintingRef`], or `None` when `scryfall_id` /// is NULL (an unmatched LEFT JOIN). fn row_to_printing(r: &rusqlite::Row, at: usize) -> rusqlite::Result> { let Some(scryfall_id) = r.get::<_, Option>(at)? else { return Ok(None); }; let finishes: String = r.get(at + 3)?; Ok(Some(PrintingRef { scryfall_id, set_code: r.get(at + 1)?, collector_number: r.get(at + 2)?, finishes: split_finishes(&finishes), rarity: r.get(at + 4)?, image_uri: r.get(at + 5)?, back_image_uri: r.get(at + 6)?, price_usd: r.get(at + 7)?, lang: r.get(at + 8)?, released_at: r.get(at + 9)?, })) } fn split_finishes(joined: &str) -> Vec { joined .split(',') .filter(|f| !f.is_empty()) .map(str::to_string) .collect() } impl Catalog { /// The printing columns [`row_to_printing`] reads, for the printing /// aliased `p`. `lang`/`released_at` read as empty on a reference.db /// built before they shipped. fn printing_columns(&self) -> &'static str { if self.has_printing_details { "p.scryfall_id, p.set_code, p.collector_number, p.finishes, p.rarity, p.image_uri, p.back_image_uri, p.price_usd, p.lang, p.released_at" } else { "p.scryfall_id, p.set_code, p.collector_number, p.finishes, p.rarity, p.image_uri, p.back_image_uri, p.price_usd, '', ''" } } /// Every paper printing of a card, newest first — the printing picker. /// Arena/MTGO-only printings are left out, since no physical copy can be /// one (and a deck's art should be something you could own). pub fn list_printings(&self, oracle_id: &str) -> Result> { // `digital` predates `released_at`, so a db can have the filter but // not the ordering. Checked here rather than cached on open: only the // picker needs it. let filter = if crate::db::has_reference_column(&self.conn, "printings", "digital")? { "AND p.digital = 0" } else { "" }; let order = if self.has_printing_details { "p.released_at DESC, p.rowid" } else { "p.rowid" }; let mut stmt = self.conn.prepare(&format!( "SELECT {cols} FROM {REF_DB}.printings p WHERE p.oracle_id = ?1 {filter} ORDER BY {order}", cols = self.printing_columns() ))?; let rows = stmt .query_map(params![oracle_id], |r| row_to_printing(r, 0))? .collect::, _>>()?; Ok(rows.into_iter().flatten().collect()) } /// The printing of `oracle_id` with this set code (case-insensitive) and /// collector number — how decklists name a printing (`(2X2) 315`). pub fn find_printing( &self, oracle_id: &str, set_code: &str, collector_number: &str, ) -> Result> { Ok(self .conn .query_row( &format!( "SELECT scryfall_id FROM {REF_DB}.printings WHERE oracle_id = ?1 AND lower(set_code) = lower(?2) AND collector_number = ?3 LIMIT 1" ), params![oracle_id, set_code, collector_number], |r| r.get(0), ) .optional()?) } /// A card's tracked copy groups, oldest first. pub fn list_library_copies(&self, oracle_id: &str) -> Result> { let mut stmt = self.conn.prepare(&format!( "SELECT c.scryfall_id, c.finish, c.quantity, {cols} FROM library_card_copies c JOIN library_cards lc ON lc.id = c.library_card_id LEFT JOIN {REF_DB}.printings p ON p.scryfall_id = c.scryfall_id WHERE lc.oracle_id = ?1 ORDER BY c.id", cols = self.printing_columns() ))?; let rows = stmt .query_map(params![oracle_id], |r| { Ok(LibraryCopy { scryfall_id: r.get(0)?, finish: r.get(1)?, quantity: r.get(2)?, printing: row_to_printing(r, 3)?, }) })? .collect::, _>>()?; Ok(rows) } /// Check that `scryfall_id` is a printing of `oracle_id` and return the /// finishes it comes in (empty when Scryfall lists none). pub(crate) fn printing_finishes(&self, oracle_id: &str, scryfall_id: &str) -> Result> { let finishes: Option = self .conn .query_row( &format!( "SELECT finishes FROM {REF_DB}.printings WHERE scryfall_id = ?1 AND oracle_id = ?2" ), params![scryfall_id, oracle_id], |r| r.get(0), ) .optional()?; finishes .map(|f| split_finishes(&f)) .ok_or_else(|| CoreError::Other("that isn't a printing of this card".into())) } /// Set how many owned copies of a card are `scryfall_id` in `finish`. /// `quantity <= 0` removes the group (never validated against the /// reference db, so a group whose printing has since vanished can still /// be removed). Grows `quantity_owned` when the groups would otherwise /// exceed it. Returns the card's owned quantity afterwards. pub fn set_library_copy( &self, oracle_id: &str, scryfall_id: &str, finish: &str, quantity: i64, ) -> Result { if quantity > 0 { if !FINISHES.contains(&finish) { return Err(CoreError::Other(format!("unknown finish \"{finish}\""))); } let finishes = self.printing_finishes(oracle_id, scryfall_id)?; if !finishes.is_empty() && !finishes.iter().any(|f| f == finish) { return Err(CoreError::Other(format!( "that printing doesn't come in {finish}" ))); } } let tx = self.conn.unchecked_transaction()?; tx.execute( "INSERT INTO library_cards(oracle_id) VALUES (?1) ON CONFLICT(oracle_id) DO NOTHING", params![oracle_id], )?; let card_id: i64 = tx.query_row( "SELECT id FROM library_cards WHERE oracle_id = ?1", params![oracle_id], |r| r.get(0), )?; if quantity <= 0 { tx.execute( "DELETE FROM library_card_copies WHERE library_card_id = ?1 AND scryfall_id = ?2 AND finish = ?3", params![card_id, scryfall_id, finish], )?; } else { tx.execute( "INSERT INTO library_card_copies(library_card_id, scryfall_id, finish, quantity) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(library_card_id, scryfall_id, finish) DO UPDATE SET quantity = excluded.quantity", params![card_id, scryfall_id, finish, quantity], )?; } tx.execute( "UPDATE library_cards SET quantity_owned = MAX(quantity_owned, (SELECT COALESCE(SUM(quantity), 0) FROM library_card_copies WHERE library_card_id = ?1)) WHERE id = ?1", params![card_id], )?; let owned: i64 = tx.query_row( "SELECT quantity_owned FROM library_cards WHERE id = ?1", params![card_id], |r| r.get(0), )?; tx.commit()?; Ok(owned) } /// Trim a card's copy groups until they fit within `quantity_owned`, /// most recently added first: the newest annotation is the likeliest to /// be the copy that just left, and older entries are kept intact. pub(crate) fn clamp_copies(&self, oracle_id: &str) -> Result<()> { let Some((card_id, owned)) = self .conn .query_row( "SELECT id, quantity_owned FROM library_cards WHERE oracle_id = ?1", params![oracle_id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)), ) .optional()? else { return Ok(()); }; let groups: Vec<(i64, i64)> = { let mut stmt = self.conn.prepare( "SELECT id, quantity FROM library_card_copies WHERE library_card_id = ?1 ORDER BY id", )?; let rows = stmt.query_map(params![card_id], |r| Ok((r.get(0)?, r.get(1)?)))?; rows.collect::>()? }; let mut excess = groups.iter().map(|(_, q)| q).sum::() - owned; for (id, qty) in groups.iter().rev() { if excess <= 0 { break; } let cut = (*qty).min(excess); if cut == *qty { self.conn .execute("DELETE FROM library_card_copies WHERE id = ?1", params![id])?; } else { self.conn.execute( "UPDATE library_card_copies SET quantity = ?1 WHERE id = ?2", params![qty - cut, id], )?; } excess -= cut; } Ok(()) } /// Choose which printing's art a deck slot shows, or `None` for the /// card's default. Fails if the card isn't in the deck. pub fn set_deck_card_printing( &self, deck_id: i64, oracle_id: &str, scryfall_id: Option<&str>, ) -> Result<()> { if let Some(id) = scryfall_id { self.printing_finishes(oracle_id, id)?; } let changed = self.conn.execute( "UPDATE deck_cards SET preferred_scryfall_id = ?1 WHERE deck_id = ?2 AND oracle_id = ?3", params![scryfall_id, deck_id, oracle_id], )?; if changed == 0 { return Err(CoreError::Other("that card isn't in this deck".into())); } Ok(()) } } #[cfg(test)] mod tests { use crate::test_support; use crate::Catalog; /// Give Sol Ring two extra printings alongside the fixture's own /// ("print-sol-ring", no finishes listed, no date). fn add_sol_ring_printings(cat: &Catalog) { cat.connection() .execute_batch( "INSERT INTO ref.printings (scryfall_id, oracle_id, set_code, collector_number, finishes, image_uri, back_image_uri, released_at, digital) VALUES ('sol-2x2', 'sol-ring', '2x2', '315', 'nonfoil,foil', 'http://img/2x2', '', '2022-07-08', 0), ('sol-c21', 'sol-ring', 'c21', '263', 'nonfoil', 'http://img/c21', 'http://img/c21-back', '2021-04-23', 0), ('sol-arena', 'sol-ring', 'ymid', '1', 'nonfoil', 'http://img/arena', '', '2023-01-01', 1);", ) .unwrap(); } #[test] fn list_printings_is_newest_first_and_paper_only() { let fx = test_support::make(); let cat = &fx.catalog; add_sol_ring_printings(cat); let ids: Vec<_> = cat .list_printings("sol-ring") .unwrap() .into_iter() .map(|p| p.scryfall_id) .collect(); // The fixture printing has no date, so it sorts last. assert_eq!(ids, vec!["sol-2x2", "sol-c21", "print-sol-ring"]); let p = &cat.list_printings("sol-ring").unwrap()[0]; assert_eq!(p.finishes, vec!["nonfoil", "foil"]); assert_eq!(p.set_code, "2x2"); } #[test] fn find_printing_matches_set_case_insensitively() { let fx = test_support::make(); let cat = &fx.catalog; add_sol_ring_printings(cat); assert_eq!( cat.find_printing("sol-ring", "2X2", "315").unwrap().as_deref(), Some("sol-2x2") ); assert_eq!(cat.find_printing("sol-ring", "2X2", "999").unwrap(), None); // Right set and number, wrong card. assert_eq!(cat.find_printing("lightning-bolt", "2X2", "315").unwrap(), None); } #[test] fn copy_groups_track_several_printings_of_one_card() { let fx = test_support::make(); let cat = &fx.catalog; add_sol_ring_printings(cat); cat.set_owned("sol-ring", 5).unwrap(); cat.set_library_copy("sol-ring", "sol-2x2", "nonfoil", 2).unwrap(); cat.set_library_copy("sol-ring", "sol-2x2", "foil", 1).unwrap(); cat.set_library_copy("sol-ring", "sol-c21", "nonfoil", 1).unwrap(); let copies = cat.list_library_copies("sol-ring").unwrap(); let summary: Vec<_> = copies .iter() .map(|c| (c.scryfall_id.as_str(), c.finish.as_str(), c.quantity)) .collect(); assert_eq!( summary, vec![("sol-2x2", "nonfoil", 2), ("sol-2x2", "foil", 1), ("sol-c21", "nonfoil", 1)] ); assert_eq!(copies[0].printing.as_ref().unwrap().set_code, "2x2"); // Tracking within the owned total doesn't change it. let lib = cat.list_library().unwrap(); assert_eq!(lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap().quantity_owned, 5); // Setting a group again updates it in place; zero removes it. cat.set_library_copy("sol-ring", "sol-2x2", "nonfoil", 3).unwrap(); cat.set_library_copy("sol-ring", "sol-c21", "nonfoil", 0).unwrap(); let copies = cat.list_library_copies("sol-ring").unwrap(); assert_eq!(copies.len(), 2); assert_eq!(copies[0].quantity, 3); } #[test] fn tracking_more_copies_than_owned_grows_ownership() { let fx = test_support::make(); let cat = &fx.catalog; add_sol_ring_printings(cat); cat.set_owned("sol-ring", 1).unwrap(); assert_eq!(cat.set_library_copy("sol-ring", "sol-2x2", "foil", 3).unwrap(), 3); // Even for a card not yet in the library at all. assert_eq!(cat.set_library_copy("lightning-bolt", "print-lightning-bolt", "nonfoil", 2).unwrap(), 2); // Lowering a group never lowers ownership. assert_eq!(cat.set_library_copy("sol-ring", "sol-2x2", "foil", 1).unwrap(), 3); } #[test] fn lowering_ownership_trims_the_newest_groups_first() { let fx = test_support::make(); let cat = &fx.catalog; add_sol_ring_printings(cat); cat.set_owned("sol-ring", 5).unwrap(); cat.set_library_copy("sol-ring", "sol-2x2", "nonfoil", 2).unwrap(); // oldest cat.set_library_copy("sol-ring", "sol-2x2", "foil", 1).unwrap(); cat.set_library_copy("sol-ring", "sol-c21", "nonfoil", 2).unwrap(); // newest // 5 tracked, now 3 owned: dropping the newest group (2) is enough. cat.set_owned("sol-ring", 3).unwrap(); let q: Vec<_> = cat .list_library_copies("sol-ring") .unwrap() .into_iter() .map(|c| (c.scryfall_id, c.finish, c.quantity)) .collect(); assert_eq!( q, vec![("sol-2x2".into(), "nonfoil".into(), 2), ("sol-2x2".into(), "foil".into(), 1)] ); // Through the decrement path too, partially trimming a group. cat.add_owned("sol-ring", -2).unwrap(); let q: Vec<_> = cat .list_library_copies("sol-ring") .unwrap() .into_iter() .map(|c| (c.finish, c.quantity)) .collect(); assert_eq!(q, vec![("nonfoil".into(), 1)]); } #[test] fn copies_are_validated_against_the_card_and_its_finishes() { let fx = test_support::make(); let cat = &fx.catalog; add_sol_ring_printings(cat); // A printing of a different card. assert!(cat.set_library_copy("sol-ring", "print-lightning-bolt", "nonfoil", 1).is_err()); // A finish this printing doesn't come in. assert!(cat.set_library_copy("sol-ring", "sol-c21", "foil", 1).is_err()); // Not a finish at all. assert!(cat.set_library_copy("sol-ring", "sol-2x2", "shiny", 1).is_err()); // A printing that lists no finishes accepts any real one. cat.set_library_copy("sol-ring", "print-sol-ring", "etched", 1).unwrap(); } #[test] fn a_copy_whose_printing_vanished_is_listed_and_removable() { let fx = test_support::make(); let cat = &fx.catalog; add_sol_ring_printings(cat); cat.set_library_copy("sol-ring", "sol-c21", "nonfoil", 1).unwrap(); // A re-ingest that no longer has it. cat.connection() .execute("DELETE FROM ref.printings WHERE scryfall_id = 'sol-c21'", []) .unwrap(); let copies = cat.list_library_copies("sol-ring").unwrap(); assert_eq!(copies.len(), 1); assert!(copies[0].printing.is_none()); cat.set_library_copy("sol-ring", "sol-c21", "nonfoil", 0).unwrap(); assert!(cat.list_library_copies("sol-ring").unwrap().is_empty()); } #[test] fn a_deck_slot_shows_its_chosen_printing() { let fx = test_support::make(); let cat = &fx.catalog; add_sol_ring_printings(cat); let d = cat.create_deck("Art", None).unwrap(); cat.set_deck_card(d.id, "sol-ring", 1, false, false).unwrap(); let sol = |cat: &Catalog| { cat.deck_contents(d.id) .unwrap() .into_iter() .find(|e| e.card.oracle_id == "sol-ring") .unwrap() }; // Unset: the default image. assert_eq!(sol(cat).card.image_uri, "http://img/sol-ring"); assert_eq!(sol(cat).preferred_scryfall_id, None); // Set: both faces come from the chosen printing. cat.set_deck_card_printing(d.id, "sol-ring", Some("sol-c21")).unwrap(); let e = sol(cat); assert_eq!(e.preferred_scryfall_id.as_deref(), Some("sol-c21")); assert_eq!(e.card.image_uri, "http://img/c21"); assert_eq!(e.card.back_image_uri, "http://img/c21-back"); // Survives a quantity/board change. cat.set_deck_card(d.id, "sol-ring", 2, false, true).unwrap(); assert_eq!(sol(cat).card.image_uri, "http://img/c21"); // Kept by a duplicate. let copy = cat.duplicate_deck(d.id, "Art copy").unwrap(); let dup = cat.deck_contents(copy.id).unwrap(); assert_eq!(dup[0].preferred_scryfall_id.as_deref(), Some("sol-c21")); // Dangling after a re-ingest: falls back to the default. cat.connection() .execute("DELETE FROM ref.printings WHERE scryfall_id = 'sol-c21'", []) .unwrap(); let e = sol(cat); assert_eq!(e.card.image_uri, "http://img/sol-ring"); assert_eq!(e.card.back_image_uri, ""); // Cleared. cat.set_deck_card_printing(d.id, "sol-ring", None).unwrap(); assert_eq!(sol(cat).preferred_scryfall_id, None); } #[test] fn deck_printing_is_validated() { let fx = test_support::make(); let cat = &fx.catalog; add_sol_ring_printings(cat); let d = cat.create_deck("Art", None).unwrap(); cat.set_deck_card(d.id, "sol-ring", 1, false, false).unwrap(); // Not a printing of this card. assert!(cat.set_deck_card_printing(d.id, "sol-ring", Some("print-lightning-bolt")).is_err()); // Card not in the deck. assert!(cat.set_deck_card_printing(d.id, "lightning-bolt", Some("print-lightning-bolt")).is_err()); } }