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>
150 lines
5.6 KiB
Rust
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);
|
|
}
|
|
}
|