Files
mtgc/crates/mtgc-core/src/db.rs
garrettmills 2b361c4a85 Add a Card Search tab and fix horizontal-card (Battle/Plane) preview orientation
Card Search browses the whole downloaded card universe (not just owned
cards), reusing the Library tab's filter/group/sort/list/tiles UI; results
show ownership and offer quick "own" and "add to deck" actions.

Battles, Planes, and Phenomena store their Scryfall image portrait-shaped
with the content itself rotated, so hover/click previews now detect this
(a new `horizontal` flag threaded through ingest and every card query, with
graceful fallback for a reference.db built before the column existed) and
rotate the image back into place. Single-faced and non-DFC cases are
verified fixed; a double-faced Battle's front face still needs another pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 20:55:17 -05:00

150 lines
5.6 KiB
Rust

//! 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<String>`.
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<Connection> {
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<Connection> {
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<bool> {
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);
}
}