Phase 1c: Extract database service to services/database.py (#9)

This commit is contained in:
KoalaTrapLord authored and GitHub committed 2026-03-17 04:40:44 -04:00
1 parent 7baae153ae
commit e14a6f9500
3 files changed
+274 -251

No files matched your search

+6 -251
View File
@@ -140,257 +140,12 @@ from models.responses import (
)
# ══════════════════════════════════════════════════════════════
# Collection Database
# ══════════════════════════════════════════════════════════════
COLLECTION_DB_PATH = Path(__file__).parent.parent / "collection.db"
_db_local = threading.local()
def _get_db_conn() -> sqlite3.Connection:
if not hasattr(_db_local, "conn") or _db_local.conn is None:
conn = sqlite3.connect(str(COLLECTION_DB_PATH), check_same_thread=False)
conn.row_factory = sqlite3.Row
_db_local.conn = conn
return _db_local.conn
def init_collection_db():
conn = sqlite3.connect(str(COLLECTION_DB_PATH), check_same_thread=False)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.executescript("""
CREATE TABLE IF NOT EXISTS card_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type_line TEXT DEFAULT '',
subtypes TEXT DEFAULT '',
is_legendary INTEGER DEFAULT 0,
is_basic INTEGER DEFAULT 0,
color_identity TEXT DEFAULT '',
cmc REAL DEFAULT 0,
oracle_text TEXT DEFAULT '',
keywords TEXT DEFAULT '',
tcg_price REAL DEFAULT 0,
salt_score REAL DEFAULT 0,
is_game_changer INTEGER DEFAULT 0,
category TEXT DEFAULT '',
scryfall_id TEXT DEFAULT '',
tcgplayer_id TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS collection_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type_line TEXT DEFAULT '',
subtypes TEXT DEFAULT '',
is_legendary INTEGER DEFAULT 0,
is_basic INTEGER DEFAULT 0,
color_identity TEXT DEFAULT '',
cmc REAL DEFAULT 0,
mana_cost TEXT DEFAULT '',
oracle_text TEXT DEFAULT '',
keywords TEXT DEFAULT '',
power TEXT DEFAULT '',
toughness TEXT DEFAULT '',
rarity TEXT DEFAULT '',
set_name TEXT DEFAULT '',
edhrec_rank INTEGER DEFAULT 0,
tcg_price REAL DEFAULT 0,
salt_score REAL DEFAULT 0,
is_game_changer INTEGER DEFAULT 0,
category TEXT DEFAULT '',
scryfall_id TEXT DEFAULT '',
tcgplayer_id TEXT DEFAULT '',
quantity INTEGER DEFAULT 1,
finish TEXT DEFAULT 'NORMAL',
condition TEXT DEFAULT '',
language TEXT DEFAULT '',
notes TEXT DEFAULT '',
tags TEXT DEFAULT '',
set_code TEXT DEFAULT '',
collector_number TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_ce_name ON collection_entries(name);
CREATE INDEX IF NOT EXISTS idx_ce_color_identity ON collection_entries(color_identity);
CREATE INDEX IF NOT EXISTS idx_ce_type_line ON collection_entries(type_line);
CREATE INDEX IF NOT EXISTS idx_ce_is_legendary ON collection_entries(is_legendary);
CREATE INDEX IF NOT EXISTS idx_ce_cmc ON collection_entries(cmc);
CREATE INDEX IF NOT EXISTS idx_ce_category ON collection_entries(category);
CREATE INDEX IF NOT EXISTS idx_ce_set_code ON collection_entries(set_code);
CREATE INDEX IF NOT EXISTS idx_ce_collector_number ON collection_entries(collector_number);
CREATE INDEX IF NOT EXISTS idx_ce_finish ON collection_entries(finish);
CREATE INDEX IF NOT EXISTS idx_cr_name ON card_records(name);
CREATE TABLE IF NOT EXISTS decks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
commander_scryfall_id TEXT DEFAULT '',
commander_name TEXT DEFAULT '',
color_identity TEXT DEFAULT '[]',
strategy_tag TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS deck_cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
deck_id INTEGER NOT NULL,
scryfall_id TEXT NOT NULL,
card_name TEXT DEFAULT '',
quantity INTEGER DEFAULT 1,
is_commander INTEGER DEFAULT 0,
role_tag TEXT DEFAULT '',
FOREIGN KEY (deck_id) REFERENCES decks(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_dc_deck_id ON deck_cards(deck_id);
CREATE INDEX IF NOT EXISTS idx_dc_scryfall_id ON deck_cards(scryfall_id);
CREATE INDEX IF NOT EXISTS idx_dk_commander ON decks(commander_scryfall_id);
""")
conn.commit()
_migrate_columns = [
("mana_cost", "TEXT DEFAULT ''"),
("power", "TEXT DEFAULT ''"),
("toughness", "TEXT DEFAULT ''"),
("rarity", "TEXT DEFAULT ''"),
("set_name", "TEXT DEFAULT ''"),
("edhrec_rank", "INTEGER DEFAULT 0"),
]
existing_cols = {row[1] for row in conn.execute("PRAGMA table_info(collection_entries)").fetchall()}
for col_name, col_def in _migrate_columns:
if col_name not in existing_cols:
conn.execute(f"ALTER TABLE collection_entries ADD COLUMN {col_name} {col_def}")
log_collect.info(f" Migration: added column '{col_name}' to collection_entries")
conn.commit()
conn.close()
log_collect.info(f" Collection DB: {COLLECTION_DB_PATH}")
# ══════════════════════════════════════════════════════════════
# Collection Utility Helpers
# ══════════════════════════════════════════════════════════════
_JSON_FIELDS = ("category", "color_identity", "subtypes", "keywords")
VALID_SORT_FIELDS = {"name", "cmc", "tcg_price", "salt_score", "category", "color_identity", "quantity", "type_line", "finish", "rarity", "set_code", "power", "toughness", "edhrec_rank"}
def _row_to_dict(row) -> dict:
if row is None:
return None
d = dict(row)
for f in _JSON_FIELDS:
if f in d and isinstance(d[f], str):
try:
d[f] = json.loads(d[f])
except (json.JSONDecodeError, TypeError):
d[f] = []
return d
def _snake_to_camel(s: str) -> str:
parts = s.split("_")
return parts[0] + "".join(p.capitalize() for p in parts[1:])
def _add_image_url(card: dict) -> dict:
scryfall_id = card.get("scryfall_id", "")
if scryfall_id:
card["imageUrl"] = f"https://api.scryfall.com/cards/{scryfall_id}?format=image&version=normal"
else:
card["imageUrl"] = None
for key in list(card.keys()):
if "_" in key:
camel = _snake_to_camel(key)
if camel not in card:
card[camel] = card[key]
return card
def _build_collection_filters(
q=None, colors=None, types=None, isLegendary=None, isBasic=None,
isGameChanger=None, highSalt=None, finish=None, cmcMin=None, cmcMax=None,
priceMin=None, priceMax=None, category=None, rarity=None, setCode=None,
powerMin=None, powerMax=None, toughMin=None, toughMax=None, keyword=None,
edhrecMin=None, edhrecMax=None, qtyMin=None, qtyMax=None,
) -> tuple:
conditions = []
params = []
if q:
like_q = f"%{q}%"
conditions.append("(name LIKE ? OR type_line LIKE ? OR oracle_text LIKE ?)")
params.extend([like_q, like_q, like_q])
if colors:
for color in [c.strip().upper() for c in colors.split(",") if c.strip()]:
conditions.append("color_identity LIKE ?")
params.append(f'%"{color}"%')
if types:
tc = []
for t in [t.strip() for t in types.split(",") if t.strip()]:
tc.append("type_line LIKE ?")
params.append(f"%{t}%")
if tc:
conditions.append(f"({' OR '.join(tc)})")
if isLegendary is not None:
conditions.append("is_legendary = ?"); params.append(1 if isLegendary else 0)
if isBasic is not None:
conditions.append("is_basic = ?"); params.append(1 if isBasic else 0)
if isGameChanger is not None:
conditions.append("is_game_changer = ?"); params.append(1 if isGameChanger else 0)
if highSalt:
conditions.append("salt_score > 2.0")
if finish:
conditions.append("finish = ?"); params.append(finish.upper())
if cmcMin is not None:
conditions.append("cmc >= ?"); params.append(cmcMin)
if cmcMax is not None:
conditions.append("cmc <= ?"); params.append(cmcMax)
if priceMin is not None:
conditions.append("tcg_price >= ?"); params.append(priceMin)
if priceMax is not None:
conditions.append("tcg_price <= ?"); params.append(priceMax)
if category:
cc = []
for c in [c.strip() for c in category.split(",") if c.strip()]:
cc.append("category LIKE ?"); params.append(f'%"{c}"%')
if cc:
conditions.append(f"({' OR '.join(cc)})")
if rarity:
rl = [r.strip().lower() for r in rarity.split(",") if r.strip()]
if rl:
conditions.append(f"LOWER(rarity) IN ({','.join(['?']*len(rl))})"); params.extend(rl)
if setCode:
sl = [s.strip().upper() for s in setCode.split(",") if s.strip()]
if sl:
conditions.append(f"UPPER(set_code) IN ({','.join(['?']*len(sl))})"); params.extend(sl)
if powerMin is not None:
conditions.append("CAST(power AS REAL) >= ?"); params.append(float(powerMin))
if powerMax is not None:
conditions.append("CAST(power AS REAL) <= ?"); params.append(float(powerMax))
if toughMin is not None:
conditions.append("CAST(toughness AS REAL) >= ?"); params.append(float(toughMin))
if toughMax is not None:
conditions.append("CAST(toughness AS REAL) <= ?"); params.append(float(toughMax))
if keyword:
for kw in [k.strip() for k in keyword.split(",") if k.strip()]:
conditions.append("keywords LIKE ?"); params.append(f"%{kw}%")
if edhrecMin is not None:
conditions.append("edhrec_rank >= ?"); params.append(edhrecMin)
if edhrecMax is not None:
conditions.append("edhrec_rank <= ?"); params.append(edhrecMax)
if qtyMin is not None:
conditions.append("quantity >= ?"); params.append(qtyMin)
if qtyMax is not None:
conditions.append("quantity <= ?"); params.append(qtyMax)
where_str = ("WHERE " + " AND ".join(conditions)) if conditions else ""
return where_str, params
# ══════════════════════════════════════════════════════════════
# Collection Database (imported from services/database.py)
from services.database import (
COLLECTION_DB_PATH, _get_db_conn, init_collection_db,
_JSON_FIELDS, VALID_SORT_FIELDS,
_row_to_dict, _snake_to_camel, _add_image_url, _build_collection_filters,
)
# Card Role Detection
# ══════════════════════════════════════════════════════════════
+1
View File
@@ -0,0 +1 @@
"""Services for Commander AI Lab."""
+267
View File
@@ -0,0 +1,267 @@
"""SQLite database management and collection utility helpers."""
from __future__ import annotations
import io
import json
import logging
import re
import sqlite3
import threading
from pathlib import Path
from typing import Optional
log_collect = logging.getLogger("commander_ai_lab.collection")
# ══════════════════════════════════════════════════════════════
# Collection Database
# ══════════════════════════════════════════════════════════════
COLLECTION_DB_PATH = Path(__file__).parent.parent / "collection.db"
_db_local = threading.local()
def _get_db_conn() -> sqlite3.Connection:
if not hasattr(_db_local, "conn") or _db_local.conn is None:
conn = sqlite3.connect(str(COLLECTION_DB_PATH), check_same_thread=False)
conn.row_factory = sqlite3.Row
_db_local.conn = conn
return _db_local.conn
def init_collection_db():
conn = sqlite3.connect(str(COLLECTION_DB_PATH), check_same_thread=False)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.executescript("""
CREATE TABLE IF NOT EXISTS card_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type_line TEXT DEFAULT '',
subtypes TEXT DEFAULT '',
is_legendary INTEGER DEFAULT 0,
is_basic INTEGER DEFAULT 0,
color_identity TEXT DEFAULT '',
cmc REAL DEFAULT 0,
oracle_text TEXT DEFAULT '',
keywords TEXT DEFAULT '',
tcg_price REAL DEFAULT 0,
salt_score REAL DEFAULT 0,
is_game_changer INTEGER DEFAULT 0,
category TEXT DEFAULT '',
scryfall_id TEXT DEFAULT '',
tcgplayer_id TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS collection_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type_line TEXT DEFAULT '',
subtypes TEXT DEFAULT '',
is_legendary INTEGER DEFAULT 0,
is_basic INTEGER DEFAULT 0,
color_identity TEXT DEFAULT '',
cmc REAL DEFAULT 0,
mana_cost TEXT DEFAULT '',
oracle_text TEXT DEFAULT '',
keywords TEXT DEFAULT '',
power TEXT DEFAULT '',
toughness TEXT DEFAULT '',
rarity TEXT DEFAULT '',
set_name TEXT DEFAULT '',
edhrec_rank INTEGER DEFAULT 0,
tcg_price REAL DEFAULT 0,
salt_score REAL DEFAULT 0,
is_game_changer INTEGER DEFAULT 0,
category TEXT DEFAULT '',
scryfall_id TEXT DEFAULT '',
tcgplayer_id TEXT DEFAULT '',
quantity INTEGER DEFAULT 1,
finish TEXT DEFAULT 'NORMAL',
condition TEXT DEFAULT '',
language TEXT DEFAULT '',
notes TEXT DEFAULT '',
tags TEXT DEFAULT '',
set_code TEXT DEFAULT '',
collector_number TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_ce_name ON collection_entries(name);
CREATE INDEX IF NOT EXISTS idx_ce_color_identity ON collection_entries(color_identity);
CREATE INDEX IF NOT EXISTS idx_ce_type_line ON collection_entries(type_line);
CREATE INDEX IF NOT EXISTS idx_ce_is_legendary ON collection_entries(is_legendary);
CREATE INDEX IF NOT EXISTS idx_ce_cmc ON collection_entries(cmc);
CREATE INDEX IF NOT EXISTS idx_ce_category ON collection_entries(category);
CREATE INDEX IF NOT EXISTS idx_ce_set_code ON collection_entries(set_code);
CREATE INDEX IF NOT EXISTS idx_ce_collector_number ON collection_entries(collector_number);
CREATE INDEX IF NOT EXISTS idx_ce_finish ON collection_entries(finish);
CREATE INDEX IF NOT EXISTS idx_cr_name ON card_records(name);
CREATE TABLE IF NOT EXISTS decks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
commander_scryfall_id TEXT DEFAULT '',
commander_name TEXT DEFAULT '',
color_identity TEXT DEFAULT '[]',
strategy_tag TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS deck_cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
deck_id INTEGER NOT NULL,
scryfall_id TEXT NOT NULL,
card_name TEXT DEFAULT '',
quantity INTEGER DEFAULT 1,
is_commander INTEGER DEFAULT 0,
role_tag TEXT DEFAULT '',
FOREIGN KEY (deck_id) REFERENCES decks(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_dc_deck_id ON deck_cards(deck_id);
CREATE INDEX IF NOT EXISTS idx_dc_scryfall_id ON deck_cards(scryfall_id);
CREATE INDEX IF NOT EXISTS idx_dk_commander ON decks(commander_scryfall_id);
""")
conn.commit()
_migrate_columns = [
("mana_cost", "TEXT DEFAULT ''"),
("power", "TEXT DEFAULT ''"),
("toughness", "TEXT DEFAULT ''"),
("rarity", "TEXT DEFAULT ''"),
("set_name", "TEXT DEFAULT ''"),
("edhrec_rank", "INTEGER DEFAULT 0"),
]
existing_cols = {row[1] for row in conn.execute("PRAGMA table_info(collection_entries)").fetchall()}
for col_name, col_def in _migrate_columns:
if col_name not in existing_cols:
conn.execute(f"ALTER TABLE collection_entries ADD COLUMN {col_name} {col_def}")
log_collect.info(f" Migration: added column '{col_name}' to collection_entries")
conn.commit()
conn.close()
log_collect.info(f" Collection DB: {COLLECTION_DB_PATH}")
# ══════════════════════════════════════════════════════════════
# Collection Utility Helpers
# ══════════════════════════════════════════════════════════════
_JSON_FIELDS = ("category", "color_identity", "subtypes", "keywords")
VALID_SORT_FIELDS = {
"name", "cmc", "tcg_price", "salt_score", "category", "color_identity",
"quantity", "type_line", "finish", "rarity", "set_code", "power",
"toughness", "edhrec_rank",
}
def _row_to_dict(row) -> dict:
if row is None:
return None
d = dict(row)
for f in _JSON_FIELDS:
if f in d and isinstance(d[f], str):
try:
d[f] = json.loads(d[f])
except (json.JSONDecodeError, TypeError):
d[f] = []
return d
def _snake_to_camel(s: str) -> str:
parts = s.split("_")
return parts[0] + "".join(p.capitalize() for p in parts[1:])
def _add_image_url(card: dict) -> dict:
scryfall_id = card.get("scryfall_id", "")
if scryfall_id:
card["imageUrl"] = f"https://api.scryfall.com/cards/{scryfall_id}?format=image&version=normal"
else:
card["imageUrl"] = None
for key in list(card.keys()):
if "_" in key:
camel = _snake_to_camel(key)
if camel not in card:
card[camel] = card[key]
return card
def _build_collection_filters(
q=None, colors=None, types=None, isLegendary=None, isBasic=None,
isGameChanger=None, highSalt=None, finish=None, cmcMin=None, cmcMax=None,
priceMin=None, priceMax=None, category=None, rarity=None, setCode=None,
powerMin=None, powerMax=None, toughMin=None, toughMax=None, keyword=None,
edhrecMin=None, edhrecMax=None, qtyMin=None, qtyMax=None,
) -> tuple:
conditions = []
params = []
if q:
like_q = f"%{q}%"
conditions.append("(name LIKE ? OR type_line LIKE ? OR oracle_text LIKE ?)")
params.extend([like_q, like_q, like_q])
if colors:
for color in [c.strip().upper() for c in colors.split(",") if c.strip()]:
conditions.append("color_identity LIKE ?")
params.append(f"%{color}%")
if types:
tc = []
for t in [t.strip() for t in types.split(",") if t.strip()]:
tc.append("type_line LIKE ?")
params.append(f"%{t}%")
if tc:
conditions.append(f"({' OR '.join(tc)})")
if isLegendary is not None:
conditions.append("is_legendary = ?"); params.append(1 if isLegendary else 0)
if isBasic is not None:
conditions.append("is_basic = ?"); params.append(1 if isBasic else 0)
if isGameChanger is not None:
conditions.append("is_game_changer = ?"); params.append(1 if isGameChanger else 0)
if highSalt:
conditions.append("salt_score > 2.0")
if finish:
conditions.append("finish = ?"); params.append(finish.upper())
if cmcMin is not None:
conditions.append("cmc >= ?"); params.append(cmcMin)
if cmcMax is not None:
conditions.append("cmc <= ?"); params.append(cmcMax)
if priceMin is not None:
conditions.append("tcg_price >= ?"); params.append(priceMin)
if priceMax is not None:
conditions.append("tcg_price <= ?"); params.append(priceMax)
if category:
cc = []
for c in [c.strip() for c in category.split(",") if c.strip()]:
cc.append("category LIKE ?"); params.append(f"%{c}%")
if cc:
conditions.append(f"({' OR '.join(cc)})")
if rarity:
rl = [r.strip().lower() for r in rarity.split(",") if r.strip()]
if rl:
conditions.append(f"LOWER(rarity) IN ({','.join(['?']*len(rl))})"); params.extend(rl)
if setCode:
sl = [s.strip().upper() for s in setCode.split(",") if s.strip()]
if sl:
conditions.append(f"UPPER(set_code) IN ({','.join(['?']*len(sl))})"); params.extend(sl)
if powerMin is not None:
conditions.append("CAST(power AS REAL) >= ?"); params.append(float(powerMin))
if powerMax is not None:
conditions.append("CAST(power AS REAL) <= ?"); params.append(float(powerMax))
if toughMin is not None:
conditions.append("CAST(toughness AS REAL) >= ?"); params.append(float(toughMin))
if toughMax is not None:
conditions.append("CAST(toughness AS REAL) <= ?"); params.append(float(toughMax))
if keyword:
for kw in [k.strip() for k in keyword.split(",") if k.strip()]:
conditions.append("keywords LIKE ?"); params.append(f"%{kw}%")
if edhrecMin is not None:
conditions.append("edhrec_rank >= ?"); params.append(edhrecMin)
if edhrecMax is not None:
conditions.append("edhrec_rank <= ?"); params.append(edhrecMax)
if qtyMin is not None:
conditions.append("quantity >= ?"); params.append(qtyMin)
if qtyMax is not None:
conditions.append("quantity <= ?"); params.append(qtyMax)
where_str = ("WHERE " + " AND ".join(conditions)) if conditions else ""
return where_str, params