//! Database wiring: open the library, run migrations, ATTACH the reference db. use std::path::Path; use rusqlite::Connection; use crate::error::Result; /// The reference schema, applied to a freshly-created reference.db by the ingester /// (and by tests that build a fixture reference db). pub const REFERENCE_SCHEMA: &str = include_str!("sql/reference_schema.sql"); /// Ordered library migrations. Index + 1 is the schema version recorded in /// `PRAGMA user_version`. Append new migrations; never edit an applied one. const LIBRARY_MIGRATIONS: &[&str] = &[ include_str!("sql/library/0001_init.sql"), include_str!("sql/library/0002_location.sql"), include_str!("sql/library/0003_deck_format.sql"), include_str!("sql/library/0004_deck_maybeboard.sql"), ]; /// The attached alias under which the reference db is available at runtime. pub const REF_DB: &str = "ref"; /// Correlated subquery selecting one column from the same printing (the first, /// by `rowid`, with a non-empty front image) for the oracle card aliased `oc`. /// Call once per desired column (e.g. `"image_uri"`, `"back_image_uri"`) — the /// shared `ORDER BY p.rowid` ensures they all resolve to the *same* printing /// row. Returns NULL when no printing has an image; read as `Option`. pub fn image_subquery(column: &str) -> String { format!( "(SELECT p.{column} FROM {REF_DB}.printings p WHERE p.oracle_id = oc.oracle_id AND p.image_uri <> '' ORDER BY p.rowid LIMIT 1)" ) } /// Correlated subquery selecting the cheapest known USD price across *all* /// printings of the oracle card aliased `oc` — deliberately independent of /// [`image_subquery`]'s printing choice, since the printing that happens to /// supply the display image (first by rowid) is often a promo/variant with no /// listed price even when other printings of the same card do have one. /// Returns NULL (read as 0.0) when no printing has a known price. pub fn price_subquery() -> String { format!( "(SELECT MIN(p.price_usd) FROM {REF_DB}.printings p WHERE p.oracle_id = oc.oracle_id AND p.price_usd > 0)" ) } /// Create (if needed) and open a reference.db, applying the reference schema. /// Used by the ingester and by test fixtures. pub fn open_reference_for_build(path: &Path) -> Result { let conn = Connection::open(path)?; conn.execute_batch(REFERENCE_SCHEMA)?; Ok(conn) } /// Open the library db at `library_path`, run migrations, and ATTACH the /// reference db at `reference_path` under the [`REF_DB`] alias. pub fn open(library_path: &Path, reference_path: &Path) -> Result { let conn = Connection::open(library_path)?; conn.pragma_update(None, "foreign_keys", true)?; migrate_library(&conn)?; attach_reference(&conn, reference_path)?; Ok(conn) } /// ATTACH a reference db to an already-open library connection. pub fn attach_reference(conn: &Connection, reference_path: &Path) -> Result<()> { conn.execute( &format!("ATTACH DATABASE ?1 AS {REF_DB}"), [reference_path.to_string_lossy()], )?; Ok(()) } /// Whether `column` exists on `table` in the attached reference db. /// reference.db is rebuilt (not migrated) on every ingest, so a db built /// before a given column shipped simply lacks it — this lets callers degrade /// gracefully (or, for narrowly-used columns, fail with a clear "update card /// data" message) instead of a raw "no such column" SQL error. pub fn has_reference_column(conn: &Connection, table: &str, column: &str) -> Result { Ok(conn.query_row( &format!("SELECT COUNT(*) FROM pragma_table_info('{table}', '{REF_DB}') WHERE name = ?1"), [column], |r| r.get::<_, i64>(0), )? > 0) } /// Apply any library migrations newer than the recorded `user_version`. pub fn migrate_library(conn: &Connection) -> Result<()> { let current: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?; for (idx, sql) in LIBRARY_MIGRATIONS.iter().enumerate() { let version = (idx + 1) as i64; if version > current { conn.execute_batch(&format!("BEGIN;\n{sql}\nPRAGMA user_version = {version};\nCOMMIT;"))?; } } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn migrations_are_idempotent_and_bump_version() { let conn = Connection::open_in_memory().unwrap(); migrate_library(&conn).unwrap(); let v: i64 = conn .pragma_query_value(None, "user_version", |r| r.get(0)) .unwrap(); assert_eq!(v, LIBRARY_MIGRATIONS.len() as i64); // Running again is a no-op (no "table already exists" error). migrate_library(&conn).unwrap(); // Core tables exist. let n: i64 = conn .query_row( "SELECT count(*) FROM sqlite_master WHERE type='table' AND name IN ('decks','library_cards','deck_cards','library_card_copies')", [], |r| r.get(0), ) .unwrap(); assert_eq!(n, 4); } #[test] fn reference_schema_applies_with_fts() { let conn = Connection::open_in_memory().unwrap(); conn.execute_batch(REFERENCE_SCHEMA).unwrap(); // FTS5 + trigram tokenizer must be available in the bundled SQLite. conn.execute( "INSERT INTO oracle_names_fts(name, oracle_id) VALUES ('Llanowar Elves','x')", [], ) .unwrap(); let hit: i64 = conn .query_row( "SELECT count(*) FROM oracle_names_fts WHERE oracle_names_fts MATCH 'lan'", [], |r| r.get(0), ) .unwrap(); assert_eq!(hit, 1); } }