This commit is contained in:
Chris Kruining 2026-02-05 11:34:57 +01:00
parent 0801ceee6a
commit 54ba7496c6
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
19 changed files with 1765 additions and 591 deletions

View file

@ -0,0 +1,739 @@
using Microsoft.Data.Sqlite;
using Scry.Core.Models;
namespace Scry.Core.Data;
/// <summary>
/// SQLite database for storing card data and perceptual hashes.
/// Schema mirrors Scryfall's data model: oracles (game cards), sets, and cards (printings).
/// </summary>
public class CardDatabase : IDisposable
{
private readonly SqliteConnection _connection;
private readonly string _dbPath;
public CardDatabase(string dbPath)
{
_dbPath = dbPath;
_connection = new SqliteConnection($"Data Source={dbPath}");
_connection.Open();
Initialize();
}
private void Initialize()
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = """
-- Abstract game cards (oracle)
CREATE TABLE IF NOT EXISTS oracles (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
mana_cost TEXT,
cmc REAL,
type_line TEXT,
oracle_text TEXT,
colors TEXT,
color_identity TEXT,
keywords TEXT,
reserved INTEGER DEFAULT 0,
legalities TEXT,
power TEXT,
toughness TEXT
);
CREATE INDEX IF NOT EXISTS idx_oracles_name ON oracles(name);
-- MTG sets
CREATE TABLE IF NOT EXISTS sets (
id TEXT PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
set_type TEXT,
released_at TEXT,
card_count INTEGER,
icon_svg_uri TEXT,
digital INTEGER DEFAULT 0,
parent_set_code TEXT,
block TEXT
);
CREATE INDEX IF NOT EXISTS idx_sets_code ON sets(code);
-- Card printings with hashes
CREATE TABLE IF NOT EXISTS cards (
id TEXT PRIMARY KEY,
oracle_id TEXT NOT NULL,
set_id TEXT NOT NULL,
set_code TEXT,
name TEXT NOT NULL,
collector_number TEXT,
rarity TEXT,
artist TEXT,
illustration_id TEXT,
image_uri TEXT,
hash BLOB,
lang TEXT DEFAULT 'en',
prices_usd REAL,
prices_usd_foil REAL,
FOREIGN KEY (oracle_id) REFERENCES oracles(id),
FOREIGN KEY (set_id) REFERENCES sets(id)
);
CREATE INDEX IF NOT EXISTS idx_cards_oracle_id ON cards(oracle_id);
CREATE INDEX IF NOT EXISTS idx_cards_set_id ON cards(set_id);
CREATE INDEX IF NOT EXISTS idx_cards_name ON cards(name);
CREATE INDEX IF NOT EXISTS idx_cards_set_code ON cards(set_code);
-- Metadata for tracking sync state
CREATE TABLE IF NOT EXISTS metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
""";
cmd.ExecuteNonQuery();
}
#region Metadata
public async Task<string?> GetMetadataAsync(string key, CancellationToken ct = default)
{
await using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT value FROM metadata WHERE key = $key";
cmd.Parameters.AddWithValue("$key", key);
var result = await cmd.ExecuteScalarAsync(ct);
return result as string;
}
public async Task SetMetadataAsync(string key, string value, CancellationToken ct = default)
{
await using var cmd = _connection.CreateCommand();
cmd.CommandText = """
INSERT OR REPLACE INTO metadata (key, value) VALUES ($key, $value)
""";
cmd.Parameters.AddWithValue("$key", key);
cmd.Parameters.AddWithValue("$value", value);
await cmd.ExecuteNonQueryAsync(ct);
}
#endregion
#region Oracles
public async Task InsertOracleAsync(Oracle oracle, CancellationToken ct = default)
{
await using var cmd = _connection.CreateCommand();
cmd.CommandText = """
INSERT OR REPLACE INTO oracles
(id, name, mana_cost, cmc, type_line, oracle_text, colors, color_identity, keywords, reserved, legalities, power, toughness)
VALUES ($id, $name, $mana_cost, $cmc, $type_line, $oracle_text, $colors, $color_identity, $keywords, $reserved, $legalities, $power, $toughness)
""";
cmd.Parameters.AddWithValue("$id", oracle.Id);
cmd.Parameters.AddWithValue("$name", oracle.Name);
cmd.Parameters.AddWithValue("$mana_cost", oracle.ManaCost ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("$cmc", oracle.Cmc ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("$type_line", oracle.TypeLine ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("$oracle_text", oracle.OracleText ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("$colors", oracle.Colors ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("$color_identity", oracle.ColorIdentity ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("$keywords", oracle.Keywords ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("$reserved", oracle.Reserved ? 1 : 0);
cmd.Parameters.AddWithValue("$legalities", oracle.Legalities ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("$power", oracle.Power ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("$toughness", oracle.Toughness ?? (object)DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task InsertOracleBatchAsync(IEnumerable<Oracle> oracles, CancellationToken ct = default)
{
await using var transaction = await _connection.BeginTransactionAsync(ct);
try
{
await using var cmd = _connection.CreateCommand();
cmd.CommandText = """
INSERT OR REPLACE INTO oracles
(id, name, mana_cost, cmc, type_line, oracle_text, colors, color_identity, keywords, reserved, legalities, power, toughness)
VALUES ($id, $name, $mana_cost, $cmc, $type_line, $oracle_text, $colors, $color_identity, $keywords, $reserved, $legalities, $power, $toughness)
""";
var idParam = cmd.Parameters.Add("$id", SqliteType.Text);
var nameParam = cmd.Parameters.Add("$name", SqliteType.Text);
var manaCostParam = cmd.Parameters.Add("$mana_cost", SqliteType.Text);
var cmcParam = cmd.Parameters.Add("$cmc", SqliteType.Real);
var typeLineParam = cmd.Parameters.Add("$type_line", SqliteType.Text);
var oracleTextParam = cmd.Parameters.Add("$oracle_text", SqliteType.Text);
var colorsParam = cmd.Parameters.Add("$colors", SqliteType.Text);
var colorIdentityParam = cmd.Parameters.Add("$color_identity", SqliteType.Text);
var keywordsParam = cmd.Parameters.Add("$keywords", SqliteType.Text);
var reservedParam = cmd.Parameters.Add("$reserved", SqliteType.Integer);
var legalitiesParam = cmd.Parameters.Add("$legalities", SqliteType.Text);
var powerParam = cmd.Parameters.Add("$power", SqliteType.Text);
var toughnessParam = cmd.Parameters.Add("$toughness", SqliteType.Text);
foreach (var oracle in oracles)
{
ct.ThrowIfCancellationRequested();
idParam.Value = oracle.Id;
nameParam.Value = oracle.Name;
manaCostParam.Value = oracle.ManaCost ?? (object)DBNull.Value;
cmcParam.Value = oracle.Cmc ?? (object)DBNull.Value;
typeLineParam.Value = oracle.TypeLine ?? (object)DBNull.Value;
oracleTextParam.Value = oracle.OracleText ?? (object)DBNull.Value;
colorsParam.Value = oracle.Colors ?? (object)DBNull.Value;
colorIdentityParam.Value = oracle.ColorIdentity ?? (object)DBNull.Value;
keywordsParam.Value = oracle.Keywords ?? (object)DBNull.Value;
reservedParam.Value = oracle.Reserved ? 1 : 0;
legalitiesParam.Value = oracle.Legalities ?? (object)DBNull.Value;
powerParam.Value = oracle.Power ?? (object)DBNull.Value;
toughnessParam.Value = oracle.Toughness ?? (object)DBNull.Value;
await cmd.ExecuteNonQueryAsync(ct);
}
await transaction.CommitAsync(ct);
}
catch
{
await transaction.RollbackAsync(ct);
throw;
}
}
public async Task<Oracle?> GetOracleByIdAsync(string id, CancellationToken ct = default)
{
await using var cmd = _connection.CreateCommand();
cmd.CommandText = """
SELECT id, name, mana_cost, cmc, type_line, oracle_text, colors, color_identity, keywords, reserved, legalities, power, toughness
FROM oracles WHERE id = $id
""";
cmd.Parameters.AddWithValue("$id", id);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (await reader.ReadAsync(ct))
{
return ReadOracle(reader);
}
return null;
}
public async Task<Oracle?> GetOracleByNameAsync(string name, CancellationToken ct = default)
{
await using var cmd = _connection.CreateCommand();
cmd.CommandText = """
SELECT id, name, mana_cost, cmc, type_line, oracle_text, colors, color_identity, keywords, reserved, legalities, power, toughness
FROM oracles WHERE name = $name COLLATE NOCASE
""";
cmd.Parameters.AddWithValue("$name", name);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (await reader.ReadAsync(ct))
{
return ReadOracle(reader);
}
return null;
}
public async Task<HashSet<string>> GetExistingOracleIdsAsync(CancellationToken ct = default)
{
var ids = new HashSet<string>();
await using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT id FROM oracles";
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
ids.Add(reader.GetString(0));
}
return ids;
}
public async Task<int> GetOracleCountAsync(CancellationToken ct = default)
{
await using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM oracles";
var result = await cmd.ExecuteScalarAsync(ct);
return Convert.ToInt32(result);
}
private static Oracle ReadOracle(SqliteDataReader reader) => new()
{
Id = reader.GetString(0),
Name = reader.GetString(1),
ManaCost = reader.IsDBNull(2) ? null : reader.GetString(2),
Cmc = reader.IsDBNull(3) ? null : reader.GetDouble(3),
TypeLine = reader.IsDBNull(4) ? null : reader.GetString(4),
OracleText = reader.IsDBNull(5) ? null : reader.GetString(5),
Colors = reader.IsDBNull(6) ? null : reader.GetString(6),
ColorIdentity = reader.IsDBNull(7) ? null : reader.GetString(7),
Keywords = reader.IsDBNull(8) ? null : reader.GetString(8),
Reserved = reader.GetInt32(9) != 0,
Legalities = reader.IsDBNull(10) ? null : reader.GetString(10),
Power = reader.IsDBNull(11) ? null : reader.GetString(11),
Toughness = reader.IsDBNull(12) ? null : reader.GetString(12),
};
#endregion
#region Sets
public async Task InsertSetAsync(Set set, CancellationToken ct = default)
{
await using var cmd = _connection.CreateCommand();
cmd.CommandText = """
INSERT OR REPLACE INTO sets
(id, code, name, set_type, released_at, card_count, icon_svg_uri, digital, parent_set_code, block)
VALUES ($id, $code, $name, $set_type, $released_at, $card_count, $icon_svg_uri, $digital, $parent_set_code, $block)
""";
cmd.Parameters.AddWithValue("$id", set.Id);
cmd.Parameters.AddWithValue("$code", set.Code);
cmd.Parameters.AddWithValue("$name", set.Name);
cmd.Parameters.AddWithValue("$set_type", set.SetType ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("$released_at", set.ReleasedAt ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("$card_count", set.CardCount ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("$icon_svg_uri", set.IconSvgUri ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("$digital", set.Digital ? 1 : 0);
cmd.Parameters.AddWithValue("$parent_set_code", set.ParentSetCode ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("$block", set.Block ?? (object)DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task InsertSetBatchAsync(IEnumerable<Set> sets, CancellationToken ct = default)
{
await using var transaction = await _connection.BeginTransactionAsync(ct);
try
{
await using var cmd = _connection.CreateCommand();
cmd.CommandText = """
INSERT OR REPLACE INTO sets
(id, code, name, set_type, released_at, card_count, icon_svg_uri, digital, parent_set_code, block)
VALUES ($id, $code, $name, $set_type, $released_at, $card_count, $icon_svg_uri, $digital, $parent_set_code, $block)
""";
var idParam = cmd.Parameters.Add("$id", SqliteType.Text);
var codeParam = cmd.Parameters.Add("$code", SqliteType.Text);
var nameParam = cmd.Parameters.Add("$name", SqliteType.Text);
var setTypeParam = cmd.Parameters.Add("$set_type", SqliteType.Text);
var releasedAtParam = cmd.Parameters.Add("$released_at", SqliteType.Text);
var cardCountParam = cmd.Parameters.Add("$card_count", SqliteType.Integer);
var iconSvgUriParam = cmd.Parameters.Add("$icon_svg_uri", SqliteType.Text);
var digitalParam = cmd.Parameters.Add("$digital", SqliteType.Integer);
var parentSetCodeParam = cmd.Parameters.Add("$parent_set_code", SqliteType.Text);
var blockParam = cmd.Parameters.Add("$block", SqliteType.Text);
foreach (var set in sets)
{
ct.ThrowIfCancellationRequested();
idParam.Value = set.Id;
codeParam.Value = set.Code;
nameParam.Value = set.Name;
setTypeParam.Value = set.SetType ?? (object)DBNull.Value;
releasedAtParam.Value = set.ReleasedAt ?? (object)DBNull.Value;
cardCountParam.Value = set.CardCount ?? (object)DBNull.Value;
iconSvgUriParam.Value = set.IconSvgUri ?? (object)DBNull.Value;
digitalParam.Value = set.Digital ? 1 : 0;
parentSetCodeParam.Value = set.ParentSetCode ?? (object)DBNull.Value;
blockParam.Value = set.Block ?? (object)DBNull.Value;
await cmd.ExecuteNonQueryAsync(ct);
}
await transaction.CommitAsync(ct);
}
catch
{
await transaction.RollbackAsync(ct);
throw;
}
}
public async Task<Set?> GetSetByIdAsync(string id, CancellationToken ct = default)
{
await using var cmd = _connection.CreateCommand();
cmd.CommandText = """
SELECT id, code, name, set_type, released_at, card_count, icon_svg_uri, digital, parent_set_code, block
FROM sets WHERE id = $id
""";
cmd.Parameters.AddWithValue("$id", id);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (await reader.ReadAsync(ct))
{
return ReadSet(reader);
}
return null;
}
public async Task<Set?> GetSetByCodeAsync(string code, CancellationToken ct = default)
{
await using var cmd = _connection.CreateCommand();
cmd.CommandText = """
SELECT id, code, name, set_type, released_at, card_count, icon_svg_uri, digital, parent_set_code, block
FROM sets WHERE code = $code COLLATE NOCASE
""";
cmd.Parameters.AddWithValue("$code", code);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (await reader.ReadAsync(ct))
{
return ReadSet(reader);
}
return null;
}
public async Task<HashSet<string>> GetExistingSetIdsAsync(CancellationToken ct = default)
{
var ids = new HashSet<string>();
await using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT id FROM sets";
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
ids.Add(reader.GetString(0));
}
return ids;
}
public async Task<int> GetSetCountAsync(CancellationToken ct = default)
{
await using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM sets";
var result = await cmd.ExecuteScalarAsync(ct);
return Convert.ToInt32(result);
}
private static Set ReadSet(SqliteDataReader reader) => new()
{
Id = reader.GetString(0),
Code = reader.GetString(1),
Name = reader.GetString(2),
SetType = reader.IsDBNull(3) ? null : reader.GetString(3),
ReleasedAt = reader.IsDBNull(4) ? null : reader.GetString(4),
CardCount = reader.IsDBNull(5) ? null : reader.GetInt32(5),
IconSvgUri = reader.IsDBNull(6) ? null : reader.GetString(6),
Digital = reader.GetInt32(7) != 0,
ParentSetCode = reader.IsDBNull(8) ? null : reader.GetString(8),
Block = reader.IsDBNull(9) ? null : reader.GetString(9),
};
#endregion
#region Cards (Printings)
public async Task InsertCardAsync(Card card, CancellationToken ct = default)
{
await using var cmd = _connection.CreateCommand();
cmd.CommandText = """
INSERT OR REPLACE INTO cards
(id, oracle_id, set_id, set_code, name, collector_number, rarity, artist, illustration_id, image_uri, hash, lang, prices_usd, prices_usd_foil)
VALUES ($id, $oracle_id, $set_id, $set_code, $name, $collector_number, $rarity, $artist, $illustration_id, $image_uri, $hash, $lang, $prices_usd, $prices_usd_foil)
""";
cmd.Parameters.AddWithValue("$id", card.Id);
cmd.Parameters.AddWithValue("$oracle_id", card.OracleId);
cmd.Parameters.AddWithValue("$set_id", card.SetId);
cmd.Parameters.AddWithValue("$set_code", card.SetCode ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("$name", card.Name);
cmd.Parameters.AddWithValue("$collector_number", card.CollectorNumber ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("$rarity", card.Rarity ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("$artist", card.Artist ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("$illustration_id", card.IllustrationId ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("$image_uri", card.ImageUri ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("$hash", card.Hash ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("$lang", card.Lang ?? "en");
cmd.Parameters.AddWithValue("$prices_usd", card.PricesUsd ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("$prices_usd_foil", card.PricesUsdFoil ?? (object)DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task InsertCardBatchAsync(IEnumerable<Card> cards, CancellationToken ct = default)
{
await using var transaction = await _connection.BeginTransactionAsync(ct);
try
{
await using var cmd = _connection.CreateCommand();
cmd.CommandText = """
INSERT OR REPLACE INTO cards
(id, oracle_id, set_id, set_code, name, collector_number, rarity, artist, illustration_id, image_uri, hash, lang, prices_usd, prices_usd_foil)
VALUES ($id, $oracle_id, $set_id, $set_code, $name, $collector_number, $rarity, $artist, $illustration_id, $image_uri, $hash, $lang, $prices_usd, $prices_usd_foil)
""";
var idParam = cmd.Parameters.Add("$id", SqliteType.Text);
var oracleIdParam = cmd.Parameters.Add("$oracle_id", SqliteType.Text);
var setIdParam = cmd.Parameters.Add("$set_id", SqliteType.Text);
var setCodeParam = cmd.Parameters.Add("$set_code", SqliteType.Text);
var nameParam = cmd.Parameters.Add("$name", SqliteType.Text);
var collectorNumberParam = cmd.Parameters.Add("$collector_number", SqliteType.Text);
var rarityParam = cmd.Parameters.Add("$rarity", SqliteType.Text);
var artistParam = cmd.Parameters.Add("$artist", SqliteType.Text);
var illustrationIdParam = cmd.Parameters.Add("$illustration_id", SqliteType.Text);
var imageUriParam = cmd.Parameters.Add("$image_uri", SqliteType.Text);
var hashParam = cmd.Parameters.Add("$hash", SqliteType.Blob);
var langParam = cmd.Parameters.Add("$lang", SqliteType.Text);
var pricesUsdParam = cmd.Parameters.Add("$prices_usd", SqliteType.Real);
var pricesUsdFoilParam = cmd.Parameters.Add("$prices_usd_foil", SqliteType.Real);
foreach (var card in cards)
{
ct.ThrowIfCancellationRequested();
idParam.Value = card.Id;
oracleIdParam.Value = card.OracleId;
setIdParam.Value = card.SetId;
setCodeParam.Value = card.SetCode ?? (object)DBNull.Value;
nameParam.Value = card.Name;
collectorNumberParam.Value = card.CollectorNumber ?? (object)DBNull.Value;
rarityParam.Value = card.Rarity ?? (object)DBNull.Value;
artistParam.Value = card.Artist ?? (object)DBNull.Value;
illustrationIdParam.Value = card.IllustrationId ?? (object)DBNull.Value;
imageUriParam.Value = card.ImageUri ?? (object)DBNull.Value;
hashParam.Value = card.Hash ?? (object)DBNull.Value;
langParam.Value = card.Lang ?? "en";
pricesUsdParam.Value = card.PricesUsd ?? (object)DBNull.Value;
pricesUsdFoilParam.Value = card.PricesUsdFoil ?? (object)DBNull.Value;
await cmd.ExecuteNonQueryAsync(ct);
}
await transaction.CommitAsync(ct);
}
catch
{
await transaction.RollbackAsync(ct);
throw;
}
}
public async Task<Card?> GetCardByIdAsync(string id, CancellationToken ct = default)
{
await using var cmd = _connection.CreateCommand();
cmd.CommandText = """
SELECT id, oracle_id, set_id, set_code, name, collector_number, rarity, artist, illustration_id, image_uri, hash, lang, prices_usd, prices_usd_foil
FROM cards WHERE id = $id
""";
cmd.Parameters.AddWithValue("$id", id);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (await reader.ReadAsync(ct))
{
return ReadCard(reader);
}
return null;
}
public async Task<List<Card>> GetCardsByOracleIdAsync(string oracleId, CancellationToken ct = default)
{
var cards = new List<Card>();
await using var cmd = _connection.CreateCommand();
cmd.CommandText = """
SELECT id, oracle_id, set_id, set_code, name, collector_number, rarity, artist, illustration_id, image_uri, hash, lang, prices_usd, prices_usd_foil
FROM cards WHERE oracle_id = $oracle_id
""";
cmd.Parameters.AddWithValue("$oracle_id", oracleId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
cards.Add(ReadCard(reader));
}
return cards;
}
public async Task<List<Card>> GetCardsByNameAsync(string name, CancellationToken ct = default)
{
var cards = new List<Card>();
await using var cmd = _connection.CreateCommand();
cmd.CommandText = """
SELECT id, oracle_id, set_id, set_code, name, collector_number, rarity, artist, illustration_id, image_uri, hash, lang, prices_usd, prices_usd_foil
FROM cards WHERE name = $name COLLATE NOCASE
""";
cmd.Parameters.AddWithValue("$name", name);
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
cards.Add(ReadCard(reader));
}
return cards;
}
public async Task<List<Card>> GetAllCardsAsync(CancellationToken ct = default)
{
var cards = new List<Card>();
await using var cmd = _connection.CreateCommand();
cmd.CommandText = """
SELECT id, oracle_id, set_id, set_code, name, collector_number, rarity, artist, illustration_id, image_uri, hash, lang, prices_usd, prices_usd_foil
FROM cards
""";
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
cards.Add(ReadCard(reader));
}
return cards;
}
public async Task<List<Card>> GetCardsWithHashAsync(CancellationToken ct = default)
{
var cards = new List<Card>();
await using var cmd = _connection.CreateCommand();
// Join with oracles and sets to get denormalized data
cmd.CommandText = """
SELECT c.id, c.oracle_id, c.set_id, c.set_code, c.name, c.collector_number, c.rarity, c.artist,
c.illustration_id, c.image_uri, c.hash, c.lang, c.prices_usd, c.prices_usd_foil,
o.mana_cost, o.type_line, o.oracle_text, o.power, o.toughness,
s.name as set_name
FROM cards c
LEFT JOIN oracles o ON c.oracle_id = o.id
LEFT JOIN sets s ON c.set_id = s.id
WHERE c.hash IS NOT NULL
""";
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
cards.Add(ReadCardWithOracle(reader));
}
return cards;
}
public async Task<HashSet<string>> GetExistingCardIdsAsync(CancellationToken ct = default)
{
var ids = new HashSet<string>();
await using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT id FROM cards";
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
ids.Add(reader.GetString(0));
}
return ids;
}
public async Task<HashSet<string>> GetExistingCardNamesAsync(CancellationToken ct = default)
{
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
await using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT DISTINCT name FROM cards";
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
names.Add(reader.GetString(0));
}
return names;
}
public async Task<int> GetCardCountAsync(CancellationToken ct = default)
{
await using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM cards";
var result = await cmd.ExecuteScalarAsync(ct);
return Convert.ToInt32(result);
}
public async Task DeleteCardByIdAsync(string id, CancellationToken ct = default)
{
await using var cmd = _connection.CreateCommand();
cmd.CommandText = "DELETE FROM cards WHERE id = $id";
cmd.Parameters.AddWithValue("$id", id);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task ClearCardsAsync(CancellationToken ct = default)
{
await using var cmd = _connection.CreateCommand();
cmd.CommandText = "DELETE FROM cards";
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task ClearAllAsync(CancellationToken ct = default)
{
await using var cmd = _connection.CreateCommand();
cmd.CommandText = """
DELETE FROM cards;
DELETE FROM oracles;
DELETE FROM sets;
DELETE FROM metadata;
""";
await cmd.ExecuteNonQueryAsync(ct);
}
private static Card ReadCard(SqliteDataReader reader) => new()
{
Id = reader.GetString(0),
OracleId = reader.GetString(1),
SetId = reader.GetString(2),
SetCode = reader.IsDBNull(3) ? null : reader.GetString(3),
Name = reader.GetString(4),
CollectorNumber = reader.IsDBNull(5) ? null : reader.GetString(5),
Rarity = reader.IsDBNull(6) ? null : reader.GetString(6),
Artist = reader.IsDBNull(7) ? null : reader.GetString(7),
IllustrationId = reader.IsDBNull(8) ? null : reader.GetString(8),
ImageUri = reader.IsDBNull(9) ? null : reader.GetString(9),
Hash = reader.IsDBNull(10) ? null : (byte[])reader.GetValue(10),
Lang = reader.IsDBNull(11) ? null : reader.GetString(11),
PricesUsd = reader.IsDBNull(12) ? null : (decimal)reader.GetDouble(12),
PricesUsdFoil = reader.IsDBNull(13) ? null : (decimal)reader.GetDouble(13),
};
/// <summary>
/// Reads a card with joined Oracle and Set data (columns 14-19).
/// </summary>
private static Card ReadCardWithOracle(SqliteDataReader reader) => new()
{
Id = reader.GetString(0),
OracleId = reader.GetString(1),
SetId = reader.GetString(2),
SetCode = reader.IsDBNull(3) ? null : reader.GetString(3),
Name = reader.GetString(4),
CollectorNumber = reader.IsDBNull(5) ? null : reader.GetString(5),
Rarity = reader.IsDBNull(6) ? null : reader.GetString(6),
Artist = reader.IsDBNull(7) ? null : reader.GetString(7),
IllustrationId = reader.IsDBNull(8) ? null : reader.GetString(8),
ImageUri = reader.IsDBNull(9) ? null : reader.GetString(9),
Hash = reader.IsDBNull(10) ? null : (byte[])reader.GetValue(10),
Lang = reader.IsDBNull(11) ? null : reader.GetString(11),
PricesUsd = reader.IsDBNull(12) ? null : (decimal)reader.GetDouble(12),
PricesUsdFoil = reader.IsDBNull(13) ? null : (decimal)reader.GetDouble(13),
// Denormalized Oracle fields (from JOIN)
ManaCost = reader.IsDBNull(14) ? null : reader.GetString(14),
TypeLine = reader.IsDBNull(15) ? null : reader.GetString(15),
OracleText = reader.IsDBNull(16) ? null : reader.GetString(16),
Power = reader.IsDBNull(17) ? null : reader.GetString(17),
Toughness = reader.IsDBNull(18) ? null : reader.GetString(18),
SetName = reader.IsDBNull(19) ? null : reader.GetString(19),
};
#endregion
public void Dispose()
{
_connection.Dispose();
}
}

View file

@ -1,234 +0,0 @@
using Microsoft.Data.Sqlite;
using Scry.Core.Models;
namespace Scry.Core.Data;
public class CardHashDatabase : IDisposable
{
private readonly SqliteConnection _connection;
private readonly string _dbPath;
public CardHashDatabase(string dbPath)
{
_dbPath = dbPath;
_connection = new SqliteConnection($"Data Source={dbPath}");
_connection.Open();
Initialize();
}
private void Initialize()
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = """
CREATE TABLE IF NOT EXISTS card_hashes (
card_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
set_code TEXT NOT NULL,
collector_number TEXT,
hash BLOB NOT NULL,
image_uri TEXT
);
CREATE INDEX IF NOT EXISTS idx_card_hashes_name ON card_hashes(name);
CREATE INDEX IF NOT EXISTS idx_card_hashes_set ON card_hashes(set_code);
CREATE TABLE IF NOT EXISTS metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
""";
cmd.ExecuteNonQuery();
}
public async Task<string?> GetMetadataAsync(string key, CancellationToken ct = default)
{
await using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT value FROM metadata WHERE key = $key";
cmd.Parameters.AddWithValue("$key", key);
var result = await cmd.ExecuteScalarAsync(ct);
return result as string;
}
public async Task SetMetadataAsync(string key, string value, CancellationToken ct = default)
{
await using var cmd = _connection.CreateCommand();
cmd.CommandText = """
INSERT OR REPLACE INTO metadata (key, value) VALUES ($key, $value)
""";
cmd.Parameters.AddWithValue("$key", key);
cmd.Parameters.AddWithValue("$value", value);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task InsertHashAsync(CardHash hash, CancellationToken ct = default)
{
await using var cmd = _connection.CreateCommand();
cmd.CommandText = """
INSERT OR REPLACE INTO card_hashes
(card_id, name, set_code, collector_number, hash, image_uri)
VALUES ($card_id, $name, $set_code, $collector_number, $hash, $image_uri)
""";
cmd.Parameters.AddWithValue("$card_id", hash.CardId);
cmd.Parameters.AddWithValue("$name", hash.Name);
cmd.Parameters.AddWithValue("$set_code", hash.SetCode);
cmd.Parameters.AddWithValue("$collector_number", hash.CollectorNumber ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("$hash", hash.Hash);
cmd.Parameters.AddWithValue("$image_uri", hash.ImageUri ?? (object)DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task InsertHashBatchAsync(IEnumerable<CardHash> hashes, CancellationToken ct = default)
{
await using var transaction = await _connection.BeginTransactionAsync(ct);
try
{
await using var cmd = _connection.CreateCommand();
cmd.CommandText = """
INSERT OR REPLACE INTO card_hashes
(card_id, name, set_code, collector_number, hash, image_uri)
VALUES ($card_id, $name, $set_code, $collector_number, $hash, $image_uri)
""";
var cardIdParam = cmd.Parameters.Add("$card_id", SqliteType.Text);
var nameParam = cmd.Parameters.Add("$name", SqliteType.Text);
var setCodeParam = cmd.Parameters.Add("$set_code", SqliteType.Text);
var collectorNumberParam = cmd.Parameters.Add("$collector_number", SqliteType.Text);
var hashParam = cmd.Parameters.Add("$hash", SqliteType.Blob);
var imageUriParam = cmd.Parameters.Add("$image_uri", SqliteType.Text);
foreach (var hash in hashes)
{
ct.ThrowIfCancellationRequested();
cardIdParam.Value = hash.CardId;
nameParam.Value = hash.Name;
setCodeParam.Value = hash.SetCode;
collectorNumberParam.Value = hash.CollectorNumber ?? (object)DBNull.Value;
hashParam.Value = hash.Hash;
imageUriParam.Value = hash.ImageUri ?? (object)DBNull.Value;
await cmd.ExecuteNonQueryAsync(ct);
}
await transaction.CommitAsync(ct);
}
catch
{
await transaction.RollbackAsync(ct);
throw;
}
}
public async Task<List<CardHash>> GetAllHashesAsync(CancellationToken ct = default)
{
var hashes = new List<CardHash>();
await using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT card_id, name, set_code, collector_number, hash, image_uri FROM card_hashes";
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
hashes.Add(new CardHash
{
CardId = reader.GetString(0),
Name = reader.GetString(1),
SetCode = reader.GetString(2),
CollectorNumber = reader.IsDBNull(3) ? null : reader.GetString(3),
Hash = (byte[])reader.GetValue(4),
ImageUri = reader.IsDBNull(5) ? null : reader.GetString(5)
});
}
return hashes;
}
public async Task<int> GetHashCountAsync(CancellationToken ct = default)
{
await using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM card_hashes";
var result = await cmd.ExecuteScalarAsync(ct);
return Convert.ToInt32(result);
}
public async Task<CardHash?> GetHashByIdAsync(string cardId, CancellationToken ct = default)
{
await using var cmd = _connection.CreateCommand();
cmd.CommandText = """
SELECT card_id, name, set_code, collector_number, hash, image_uri
FROM card_hashes WHERE card_id = $card_id
""";
cmd.Parameters.AddWithValue("$card_id", cardId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (await reader.ReadAsync(ct))
{
return new CardHash
{
CardId = reader.GetString(0),
Name = reader.GetString(1),
SetCode = reader.GetString(2),
CollectorNumber = reader.IsDBNull(3) ? null : reader.GetString(3),
Hash = (byte[])reader.GetValue(4),
ImageUri = reader.IsDBNull(5) ? null : reader.GetString(5)
};
}
return null;
}
public async Task<HashSet<string>> GetExistingCardIdsAsync(CancellationToken ct = default)
{
var ids = new HashSet<string>();
await using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT card_id FROM card_hashes";
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
ids.Add(reader.GetString(0));
}
return ids;
}
public async Task<HashSet<string>> GetExistingCardNamesAsync(CancellationToken ct = default)
{
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
await using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT DISTINCT name FROM card_hashes";
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
names.Add(reader.GetString(0));
}
return names;
}
public async Task DeleteByCardIdAsync(string cardId, CancellationToken ct = default)
{
await using var cmd = _connection.CreateCommand();
cmd.CommandText = "DELETE FROM card_hashes WHERE card_id = $card_id";
cmd.Parameters.AddWithValue("$card_id", cardId);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task ClearAsync(CancellationToken ct = default)
{
await using var cmd = _connection.CreateCommand();
cmd.CommandText = "DELETE FROM card_hashes";
await cmd.ExecuteNonQueryAsync(ct);
}
public void Dispose()
{
_connection.Dispose();
}
}