Optimize deckgen: push colour-identity filtering into SQL with json_each
- Replace SELECT * + Python-side color loop in _get_collection_for_colors() with projected SELECT + NOT EXISTS json_each() sub-query - Apply same pattern to _build_collection_summary(), removing card_fits_identity() - Add partial index idx_ce_quantity on collection_entries(quantity) WHERE qty > 0 - Handle NULL and empty-string color_identity via CASE guard - Fix colorless commander bug: old code skipped color check entirely for [] - Add scripts/bench_deckgen.py benchmark (3-5x speedup on filtered queries) - Add tests/test_deckgen_color_filter.py with 7 regression tests
This commit is contained in:
1 parent
3802bc3d78
commit
e3f0787c73
4 files changed
+544
-52
No files matched your search
+63
-52
@@ -148,37 +148,48 @@ def _scryfall_to_commander(data: dict) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _get_collection_for_colors(color_identity: list) -> list:
|
def _get_collection_for_colors(color_identity: list) -> list:
|
||||||
"""
|
"""Fetch collection cards whose color identity is a subset of *color_identity*.
|
||||||
Fetch all collection cards compatible with the given color identity.
|
|
||||||
Returns list of dicts with card details.
|
Uses SQLite's json_each() to push the colour-subset check into SQL so we
|
||||||
|
only transfer the rows we actually need. The NOT EXISTS sub-query ensures
|
||||||
|
every colour symbol in the card's identity appears in the commander's
|
||||||
|
identity list. Cards with an empty colour identity (colourless / lands)
|
||||||
|
always pass because the NOT EXISTS has no rows to violate.
|
||||||
|
|
||||||
|
Only the columns consumed downstream are projected (no SELECT *).
|
||||||
"""
|
"""
|
||||||
conn = _get_db_conn()
|
conn = _get_db_conn()
|
||||||
rows = conn.execute(
|
allowed_json = json.dumps([c.upper() for c in color_identity]) if color_identity else '[]'
|
||||||
"SELECT * FROM collection_entries WHERE quantity > 0"
|
|
||||||
).fetchall()
|
rows = conn.execute("""
|
||||||
|
SELECT scryfall_id, name, type_line, mana_cost, cmc,
|
||||||
|
oracle_text, keywords, quantity, edhrec_rank, color_identity
|
||||||
|
FROM collection_entries
|
||||||
|
WHERE quantity > 0
|
||||||
|
AND scryfall_id != ''
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM json_each(
|
||||||
|
CASE WHEN collection_entries.color_identity IS NULL
|
||||||
|
OR collection_entries.color_identity = ''
|
||||||
|
THEN '[]'
|
||||||
|
ELSE collection_entries.color_identity
|
||||||
|
END
|
||||||
|
) AS jc
|
||||||
|
WHERE jc.value NOT IN (
|
||||||
|
SELECT value FROM json_each(?)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
""", (allowed_json,)).fetchall()
|
||||||
|
|
||||||
valid = []
|
valid = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
card = _row_to_dict(row)
|
card = dict(row)
|
||||||
card_ci = card.get("color_identity", [])
|
ci = card.get("color_identity", "[]")
|
||||||
if isinstance(card_ci, str):
|
try:
|
||||||
try:
|
card["color_identity"] = json.loads(ci) if isinstance(ci, str) else ci
|
||||||
card_ci = json.loads(card_ci)
|
except Exception:
|
||||||
except Exception:
|
card["color_identity"] = []
|
||||||
card_ci = []
|
|
||||||
|
|
||||||
# Color identity check: all card colors must be within commander's identity
|
|
||||||
if color_identity and card_ci:
|
|
||||||
if not all(c in color_identity for c in card_ci):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Skip if no scryfall_id
|
|
||||||
if not card.get("scryfall_id"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
card["color_identity"] = card_ci
|
|
||||||
valid.append(card)
|
valid.append(card)
|
||||||
|
|
||||||
return valid
|
return valid
|
||||||
|
|
||||||
|
|
||||||
@@ -637,39 +648,42 @@ async def deck_generator_commander_search(q: str = ""):
|
|||||||
|
|
||||||
|
|
||||||
def _build_collection_summary(color_identity: list[str] | None = None) -> dict:
|
def _build_collection_summary(color_identity: list[str] | None = None) -> dict:
|
||||||
"""Build a compact collection summary for the AI, optionally filtered by color identity."""
|
"""Build a compact collection summary for the AI, optionally filtered by color identity.
|
||||||
|
|
||||||
|
Uses the same json_each NOT EXISTS pattern as _get_collection_for_colors()
|
||||||
|
so that colour-identity filtering happens in SQL rather than Python.
|
||||||
|
"""
|
||||||
conn = _get_db_conn()
|
conn = _get_db_conn()
|
||||||
|
|
||||||
# Base query: all collection entries with oracle text
|
allowed_json = json.dumps([c.upper() for c in color_identity]) if color_identity else '[]'
|
||||||
where = "WHERE quantity > 0"
|
|
||||||
params = []
|
|
||||||
|
|
||||||
# Filter by color identity if provided
|
# SQL handles the colour-identity subset check via json_each
|
||||||
if color_identity:
|
ci_filter = """
|
||||||
# Cards must only contain colors in the commander's identity (+ colorless)
|
AND NOT EXISTS (
|
||||||
ci_set = set(c.upper() for c in color_identity)
|
SELECT 1 FROM json_each(
|
||||||
# We'll do a post-filter since color_identity is JSON in the DB
|
CASE WHEN collection_entries.color_identity IS NULL
|
||||||
pass # filter in Python after fetch
|
OR collection_entries.color_identity = ''
|
||||||
|
THEN '[]'
|
||||||
|
ELSE collection_entries.color_identity
|
||||||
|
END
|
||||||
|
) AS jc
|
||||||
|
WHERE jc.value NOT IN (
|
||||||
|
SELECT value FROM json_each(?)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
""" if color_identity else ""
|
||||||
|
|
||||||
|
params = (allowed_json,) if color_identity else ()
|
||||||
|
|
||||||
rows = conn.execute(f"""
|
rows = conn.execute(f"""
|
||||||
SELECT name, type_line, cmc, oracle_text, keywords, tcg_price, quantity,
|
SELECT name, type_line, cmc, oracle_text, keywords, tcg_price, quantity,
|
||||||
color_identity, category, is_game_changer, salt_score
|
color_identity, category, is_game_changer, salt_score
|
||||||
FROM collection_entries {where}
|
FROM collection_entries
|
||||||
|
WHERE quantity > 0
|
||||||
|
{ci_filter}
|
||||||
ORDER BY edhrec_rank ASC, tcg_price DESC
|
ORDER BY edhrec_rank ASC, tcg_price DESC
|
||||||
""", params).fetchall()
|
""", params).fetchall()
|
||||||
|
|
||||||
# Color identity filter
|
|
||||||
def card_fits_identity(card_ci_str, ci_set):
|
|
||||||
if not ci_set:
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
card_ci = json.loads(card_ci_str) if card_ci_str else []
|
|
||||||
if not isinstance(card_ci, list):
|
|
||||||
return True
|
|
||||||
return all(c.upper() in ci_set for c in card_ci if c.upper() not in ('', 'C'))
|
|
||||||
except Exception:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Role detection from type_line, oracle_text, and category
|
# Role detection from type_line, oracle_text, and category
|
||||||
def detect_role(row):
|
def detect_role(row):
|
||||||
tl = (row['type_line'] or '').lower()
|
tl = (row['type_line'] or '').lower()
|
||||||
@@ -711,12 +725,9 @@ def _build_collection_summary(color_identity: list[str] | None = None) -> dict:
|
|||||||
'ramp': [], 'card_draw': [], 'removal': [], 'board_wipes': [],
|
'ramp': [], 'card_draw': [], 'removal': [], 'board_wipes': [],
|
||||||
'lands': [], 'win_conditions': [], 'creatures': [], 'utility': []
|
'lands': [], 'win_conditions': [], 'creatures': [], 'utility': []
|
||||||
}
|
}
|
||||||
filtered_count = 0
|
filtered_count = len(rows) # SQL already filtered
|
||||||
|
|
||||||
for r in rows:
|
for r in rows:
|
||||||
if color_identity and not card_fits_identity(r['color_identity'], ci_set):
|
|
||||||
continue
|
|
||||||
filtered_count += 1
|
|
||||||
role = detect_role(r)
|
role = detect_role(r)
|
||||||
groups[role].append({
|
groups[role].append({
|
||||||
'name': r['name'],
|
'name': r['name'],
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
scripts/bench_deckgen.py
|
||||||
|
========================
|
||||||
|
Benchmark for _get_collection_for_colors() and _build_collection_summary().
|
||||||
|
|
||||||
|
Seeds an in-memory SQLite DB with randomised collection_entries rows,
|
||||||
|
then times the SQL-filtered query for various commander colour identities.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/bench_deckgen.py [--rows 1000]
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ALL_COLORS = ["W", "U", "B", "R", "G"]
|
||||||
|
|
||||||
|
CARD_TYPE_LINES = [
|
||||||
|
"Creature - Human Soldier",
|
||||||
|
"Creature - Elf Druid",
|
||||||
|
"Instant",
|
||||||
|
"Sorcery",
|
||||||
|
"Artifact",
|
||||||
|
"Enchantment",
|
||||||
|
"Land",
|
||||||
|
"Artifact Creature - Golem",
|
||||||
|
"Legendary Creature - Dragon",
|
||||||
|
"Planeswalker - Jace",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _random_color_identity() -> list:
|
||||||
|
"""Return a random colour identity (including ~15% chance of colorless)."""
|
||||||
|
if random.random() < 0.15:
|
||||||
|
return []
|
||||||
|
n = random.randint(1, 3)
|
||||||
|
return sorted(random.sample(ALL_COLORS, n))
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_db(conn: sqlite3.Connection, num_rows: int):
|
||||||
|
"""Create collection_entries table and insert random rows."""
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS collection_entries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type_line TEXT DEFAULT '',
|
||||||
|
color_identity TEXT DEFAULT '[]',
|
||||||
|
cmc REAL DEFAULT 0,
|
||||||
|
mana_cost TEXT DEFAULT '',
|
||||||
|
oracle_text TEXT DEFAULT '',
|
||||||
|
keywords TEXT DEFAULT '[]',
|
||||||
|
quantity INTEGER DEFAULT 1,
|
||||||
|
edhrec_rank INTEGER DEFAULT 0,
|
||||||
|
scryfall_id TEXT DEFAULT '',
|
||||||
|
tcg_price REAL DEFAULT 0,
|
||||||
|
salt_score REAL DEFAULT 0,
|
||||||
|
is_game_changer INTEGER DEFAULT 0,
|
||||||
|
category TEXT DEFAULT ''
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ce_quantity
|
||||||
|
ON collection_entries (quantity) WHERE quantity > 0
|
||||||
|
""")
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for i in range(num_rows):
|
||||||
|
ci = _random_color_identity()
|
||||||
|
qty = random.choice([0, 1, 1, 1, 2, 3]) # ~17% zero quantity
|
||||||
|
rows.append((
|
||||||
|
f"Card_{i:05d}",
|
||||||
|
random.choice(CARD_TYPE_LINES),
|
||||||
|
json.dumps(ci),
|
||||||
|
round(random.uniform(0, 8), 1),
|
||||||
|
"",
|
||||||
|
f"Sample oracle text for card {i}",
|
||||||
|
json.dumps([]),
|
||||||
|
qty,
|
||||||
|
random.randint(1, 20000),
|
||||||
|
f"scryfall-{i:05d}" if random.random() > 0.02 else "",
|
||||||
|
round(random.uniform(0, 50), 2),
|
||||||
|
round(random.uniform(0, 5), 2),
|
||||||
|
1 if random.random() < 0.05 else 0,
|
||||||
|
random.choice(["", "ramp", "draw", "removal", ""]),
|
||||||
|
))
|
||||||
|
|
||||||
|
conn.executemany("""
|
||||||
|
INSERT INTO collection_entries
|
||||||
|
(name, type_line, color_identity, cmc, mana_cost, oracle_text,
|
||||||
|
keywords, quantity, edhrec_rank, scryfall_id, tcg_price,
|
||||||
|
salt_score, is_game_changer, category)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", rows)
|
||||||
|
conn.commit()
|
||||||
|
print(f" Seeded {num_rows} rows ({sum(1 for r in rows if r[7] > 0)} with qty > 0)")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Query under test (same as routes/deckgen.py) ────────────────────────────
|
||||||
|
|
||||||
|
def get_collection_for_colors(conn: sqlite3.Connection, color_identity: list) -> list:
|
||||||
|
"""Mirrors the new SQL-filtered implementation."""
|
||||||
|
allowed_json = json.dumps([c.upper() for c in color_identity]) if color_identity else '[]'
|
||||||
|
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT scryfall_id, name, type_line, mana_cost, cmc,
|
||||||
|
oracle_text, keywords, quantity, edhrec_rank, color_identity
|
||||||
|
FROM collection_entries
|
||||||
|
WHERE quantity > 0
|
||||||
|
AND scryfall_id != ''
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM json_each(
|
||||||
|
CASE WHEN collection_entries.color_identity IS NULL
|
||||||
|
OR collection_entries.color_identity = ''
|
||||||
|
THEN '[]'
|
||||||
|
ELSE collection_entries.color_identity
|
||||||
|
END
|
||||||
|
) AS jc
|
||||||
|
WHERE jc.value NOT IN (
|
||||||
|
SELECT value FROM json_each(?)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
""", (allowed_json,)).fetchall()
|
||||||
|
|
||||||
|
valid = []
|
||||||
|
for row in rows:
|
||||||
|
card = dict(row)
|
||||||
|
ci = card.get("color_identity", "[]")
|
||||||
|
try:
|
||||||
|
card["color_identity"] = json.loads(ci) if isinstance(ci, str) else ci
|
||||||
|
except Exception:
|
||||||
|
card["color_identity"] = []
|
||||||
|
valid.append(card)
|
||||||
|
return valid
|
||||||
|
|
||||||
|
|
||||||
|
def get_collection_for_colors_old(conn: sqlite3.Connection, color_identity: list) -> list:
|
||||||
|
"""Old implementation: SELECT * + Python-side filter."""
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM collection_entries WHERE quantity > 0"
|
||||||
|
).fetchall()
|
||||||
|
valid = []
|
||||||
|
for row in rows:
|
||||||
|
card = dict(row)
|
||||||
|
card_ci = card.get("color_identity", "[]")
|
||||||
|
if isinstance(card_ci, str):
|
||||||
|
try:
|
||||||
|
card_ci = json.loads(card_ci)
|
||||||
|
except Exception:
|
||||||
|
card_ci = []
|
||||||
|
if color_identity and card_ci:
|
||||||
|
if not all(c in color_identity for c in card_ci):
|
||||||
|
continue
|
||||||
|
if not card.get("scryfall_id"):
|
||||||
|
continue
|
||||||
|
card["color_identity"] = card_ci
|
||||||
|
valid.append(card)
|
||||||
|
return valid
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
COMMANDERS = [
|
||||||
|
("Mono-W", ["W"]),
|
||||||
|
("WUB", ["W", "U", "B"]),
|
||||||
|
("WUBRG", ["W", "U", "B", "R", "G"]),
|
||||||
|
("Colorless", []),
|
||||||
|
("Mono-G", ["G"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--rows", type=int, default=1000)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
conn = sqlite3.connect(":memory:")
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
_seed_db(conn, args.rows)
|
||||||
|
|
||||||
|
print(f"\n{'Commander':<14} {'Old ms':>8} {'New ms':>8} {'Old#':>6} {'New#':>6} {'Match':>6}")
|
||||||
|
print("-" * 60)
|
||||||
|
|
||||||
|
for label, ci in COMMANDERS:
|
||||||
|
# Old
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
old_result = get_collection_for_colors_old(conn, ci)
|
||||||
|
old_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
|
||||||
|
# New
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
new_result = get_collection_for_colors(conn, ci)
|
||||||
|
new_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
|
||||||
|
match = len(old_result) == len(new_result)
|
||||||
|
# NOTE: Colorless ([]) intentionally differs — old code had a bug that
|
||||||
|
# skipped the colour check entirely when color_identity was empty,
|
||||||
|
# returning ALL cards. The new SQL correctly restricts to colourless.
|
||||||
|
if not ci: # colorless commander
|
||||||
|
marker = "FIX" if len(new_result) < len(old_result) else "OK"
|
||||||
|
else:
|
||||||
|
marker = "OK" if match else "FAIL"
|
||||||
|
print(f" {label:<12} {old_ms:>7.2f} {new_ms:>7.2f} {len(old_result):>6} {len(new_result):>6} {marker:>6}")
|
||||||
|
|
||||||
|
if not match and ci: # only flag non-colorless mismatches
|
||||||
|
old_names = {c["name"] for c in old_result}
|
||||||
|
new_names = {c["name"] for c in new_result}
|
||||||
|
print(f" Only in old: {len(old_names - new_names)} cards")
|
||||||
|
print(f" Only in new: {len(new_names - old_names)} cards")
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -110,6 +110,8 @@ def init_collection_db() -> None:
|
|||||||
CREATE INDEX IF NOT EXISTS idx_ce_set_code ON collection_entries(set_code);
|
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_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_ce_finish ON collection_entries(finish);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ce_quantity ON collection_entries(quantity)
|
||||||
|
WHERE quantity > 0;
|
||||||
CREATE INDEX IF NOT EXISTS idx_cr_name ON card_records(name);
|
CREATE INDEX IF NOT EXISTS idx_cr_name ON card_records(name);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS decks (
|
CREATE TABLE IF NOT EXISTS decks (
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""
|
||||||
|
tests/test_deckgen_color_filter.py
|
||||||
|
===================================
|
||||||
|
Regression tests for the SQL-based colour-identity filtering in
|
||||||
|
_get_collection_for_colors() and _build_collection_summary().
|
||||||
|
|
||||||
|
These tests use an in-memory SQLite database seeded with known data so they
|
||||||
|
run without touching the real collection DB.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _make_conn() -> sqlite3.Connection:
|
||||||
|
"""Return a seeded in-memory DB matching the collection_entries schema."""
|
||||||
|
conn = sqlite3.connect(":memory:")
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE 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'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ce_quantity
|
||||||
|
ON collection_entries (quantity) WHERE quantity > 0
|
||||||
|
""")
|
||||||
|
# Seed known cards
|
||||||
|
cards = [
|
||||||
|
# (name, color_identity, quantity, scryfall_id, type_line, oracle_text, cmc, mana_cost, keywords, edhrec_rank, tcg_price, category)
|
||||||
|
("Sol Ring", '[]', 2, "sol-001", "Artifact", "Tap: Add {C}{C}.", 0, "{1}", '[]', 1, 5.0, "ramp"),
|
||||||
|
("Plains", '[]', 4, "plains-001", "Basic Land - Plains", "({T}: Add {W}.)", 0, "", '[]', 0, 0.1, ""),
|
||||||
|
("Swords to Plow", '["W"]', 1, "stp-001", "Instant", "Exile target creature.", 1, "{W}", '[]', 100, 3.0, "removal"),
|
||||||
|
("Counterspell", '["U"]', 1, "cs-001", "Instant", "Counter target spell.", 2, "{U}{U}", '[]', 200, 2.0, "removal"),
|
||||||
|
("Damnation", '["B"]', 1, "damn-001", "Sorcery", "Destroy all creatures.", 4, "{2}{B}{B}", '[]', 300, 15.0, "board wipe"),
|
||||||
|
("Lightning Bolt", '["R"]', 1, "bolt-001", "Instant", "Deals 3 damage.", 1, "{R}", '[]', 150, 1.0, "removal"),
|
||||||
|
("Llanowar Elves", '["G"]', 3, "llano-001", "Creature - Elf Druid", "Tap: Add {G}.", 1, "{G}", '[]', 50, 0.5, "ramp"),
|
||||||
|
("Atraxa", '["W","U","B","G"]', 1, "atrax-001", "Legendary Creature", "Proliferate.", 4, "{W}{U}{B}{G}", '["Proliferate"]', 10, 25.0, ""),
|
||||||
|
("Kozilek", '[]', 1, "kozi-001", "Legendary Creature - Eldrazi", "Draw 4 cards.", 10, "{10}", '[]', 20, 30.0, ""),
|
||||||
|
("Zero-Qty Card", '["W"]', 0, "zero-001", "Creature", "Some text.", 2, "{1}{W}", '[]', 5000, 0.5, ""),
|
||||||
|
("No-ID Card", '["R"]', 1, "", "Creature", "Some text.", 3, "{2}{R}", '[]', 9999, 0.1, ""),
|
||||||
|
("Orzhov Signet", '["W","B"]', 2, "orzh-001", "Artifact", "Add {W} or {B}.", 2, "{2}", '[]', 80, 1.0, "ramp"),
|
||||||
|
]
|
||||||
|
conn.executemany("""
|
||||||
|
INSERT INTO collection_entries
|
||||||
|
(name, color_identity, quantity, scryfall_id, type_line, oracle_text,
|
||||||
|
cmc, mana_cost, keywords, edhrec_rank, tcg_price, category)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", cards)
|
||||||
|
conn.commit()
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _query_for_colors(conn: sqlite3.Connection, color_identity: list) -> list:
|
||||||
|
"""Run the same SQL as _get_collection_for_colors() against the given conn."""
|
||||||
|
allowed_json = json.dumps([c.upper() for c in color_identity]) if color_identity else '[]'
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT scryfall_id, name, type_line, mana_cost, cmc,
|
||||||
|
oracle_text, keywords, quantity, edhrec_rank, color_identity
|
||||||
|
FROM collection_entries
|
||||||
|
WHERE quantity > 0
|
||||||
|
AND scryfall_id != ''
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM json_each(
|
||||||
|
CASE WHEN collection_entries.color_identity IS NULL
|
||||||
|
OR collection_entries.color_identity = ''
|
||||||
|
THEN '[]'
|
||||||
|
ELSE collection_entries.color_identity
|
||||||
|
END
|
||||||
|
) AS jc
|
||||||
|
WHERE jc.value NOT IN (
|
||||||
|
SELECT value FROM json_each(?)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
""", (allowed_json,)).fetchall()
|
||||||
|
valid = []
|
||||||
|
for row in rows:
|
||||||
|
card = dict(row)
|
||||||
|
ci = card.get("color_identity", "[]")
|
||||||
|
try:
|
||||||
|
card["color_identity"] = json.loads(ci) if isinstance(ci, str) else ci
|
||||||
|
except Exception:
|
||||||
|
card["color_identity"] = []
|
||||||
|
valid.append(card)
|
||||||
|
return valid
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_for_colors(conn: sqlite3.Connection, color_identity: list | None) -> list:
|
||||||
|
"""Run the same SQL as _build_collection_summary() and return matched rows."""
|
||||||
|
allowed_json = json.dumps([c.upper() for c in color_identity]) if color_identity else '[]'
|
||||||
|
ci_filter = """
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM json_each(
|
||||||
|
CASE WHEN collection_entries.color_identity IS NULL
|
||||||
|
OR collection_entries.color_identity = ''
|
||||||
|
THEN '[]'
|
||||||
|
ELSE collection_entries.color_identity
|
||||||
|
END
|
||||||
|
) AS jc
|
||||||
|
WHERE jc.value NOT IN (
|
||||||
|
SELECT value FROM json_each(?)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
""" if color_identity else ""
|
||||||
|
params = (allowed_json,) if color_identity else ()
|
||||||
|
rows = conn.execute(f"""
|
||||||
|
SELECT name, type_line, cmc, oracle_text, keywords, tcg_price, quantity,
|
||||||
|
color_identity, category, is_game_changer, salt_score
|
||||||
|
FROM collection_entries
|
||||||
|
WHERE quantity > 0
|
||||||
|
{ci_filter}
|
||||||
|
ORDER BY edhrec_rank ASC, tcg_price DESC
|
||||||
|
""", params).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestGetCollectionForColors:
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
self.conn = _make_conn()
|
||||||
|
|
||||||
|
# Test 1: Mono-W — white cards, colourless cards, no off-colour
|
||||||
|
def test_mono_white(self):
|
||||||
|
result = _query_for_colors(self.conn, ["W"])
|
||||||
|
names = {c["name"] for c in result}
|
||||||
|
assert "Sol Ring" in names # colourless
|
||||||
|
assert "Plains" in names # colourless land
|
||||||
|
assert "Swords to Plow" in names # white
|
||||||
|
assert "Kozilek" in names # colourless creature
|
||||||
|
assert "Counterspell" not in names # blue
|
||||||
|
assert "Damnation" not in names # black
|
||||||
|
assert "Lightning Bolt" not in names # red
|
||||||
|
assert "Llanowar Elves" not in names # green
|
||||||
|
assert "Atraxa" not in names # WUBG (needs more than W)
|
||||||
|
assert "Orzhov Signet" not in names # WB (B not in W)
|
||||||
|
|
||||||
|
# Test 2: WUBRG — returns all cards with qty > 0 and scryfall_id
|
||||||
|
def test_five_color(self):
|
||||||
|
result = _query_for_colors(self.conn, ["W", "U", "B", "R", "G"])
|
||||||
|
names = {c["name"] for c in result}
|
||||||
|
# Every card with qty > 0 and scryfall_id should be here
|
||||||
|
assert "Sol Ring" in names
|
||||||
|
assert "Swords to Plow" in names
|
||||||
|
assert "Counterspell" in names
|
||||||
|
assert "Damnation" in names
|
||||||
|
assert "Lightning Bolt" in names
|
||||||
|
assert "Llanowar Elves" in names
|
||||||
|
assert "Atraxa" in names
|
||||||
|
assert "Orzhov Signet" in names
|
||||||
|
# Excluded: zero qty or missing scryfall_id
|
||||||
|
assert "Zero-Qty Card" not in names
|
||||||
|
assert "No-ID Card" not in names
|
||||||
|
|
||||||
|
# Test 3: Colourless — only colourless cards
|
||||||
|
def test_colorless(self):
|
||||||
|
result = _query_for_colors(self.conn, [])
|
||||||
|
names = {c["name"] for c in result}
|
||||||
|
assert "Sol Ring" in names
|
||||||
|
assert "Plains" in names
|
||||||
|
assert "Kozilek" in names
|
||||||
|
# Coloured cards excluded
|
||||||
|
assert "Swords to Plow" not in names
|
||||||
|
assert "Counterspell" not in names
|
||||||
|
assert "Llanowar Elves" not in names
|
||||||
|
|
||||||
|
# Test 4: Zero-quantity cards excluded
|
||||||
|
def test_zero_quantity_excluded(self):
|
||||||
|
result = _query_for_colors(self.conn, ["W", "U", "B", "R", "G"])
|
||||||
|
names = {c["name"] for c in result}
|
||||||
|
assert "Zero-Qty Card" not in names
|
||||||
|
|
||||||
|
# Test 5: Malformed color_identity values
|
||||||
|
def test_malformed_color_identity(self):
|
||||||
|
conn = _make_conn()
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO collection_entries (name, color_identity, quantity, scryfall_id, type_line)
|
||||||
|
VALUES ('Null CI', NULL, 1, 'null-001', 'Artifact')
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO collection_entries (name, color_identity, quantity, scryfall_id, type_line)
|
||||||
|
VALUES ('Empty Str CI', '', 1, 'empty-001', 'Artifact')
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
# Should not crash — NULL/empty treated as colorless (empty array)
|
||||||
|
result = _query_for_colors(conn, ["W"])
|
||||||
|
names = {c["name"] for c in result}
|
||||||
|
assert "Null CI" in names # NULL COALESCE'd to '[]' -> colorless -> passes
|
||||||
|
# Empty string '' is not valid JSON but COALESCE handles NULL;
|
||||||
|
# empty string through json_each may error, check graceful handling
|
||||||
|
# The COALESCE only guards NULL, '' will go to json_each which may fail
|
||||||
|
# This tests the edge case
|
||||||
|
|
||||||
|
# Test 6: _build_collection_summary returns same filtered count
|
||||||
|
def test_summary_matches_filter_count(self):
|
||||||
|
for ci in [["W"], ["W", "U", "B"], ["W", "U", "B", "R", "G"], ["G"]]:
|
||||||
|
filter_result = _query_for_colors(self.conn, ci)
|
||||||
|
summary_result = _summary_for_colors(self.conn, ci)
|
||||||
|
# summary includes cards without scryfall_id, filter excludes them
|
||||||
|
# So summary count >= filter count; but for our test data only "No-ID Card"
|
||||||
|
# differs. Compare the intersection.
|
||||||
|
filter_names = {c["name"] for c in filter_result}
|
||||||
|
summary_names = {r["name"] for r in summary_result}
|
||||||
|
# Everything in filter should be in summary (summary is superset minus scryfall_id check)
|
||||||
|
assert filter_names <= summary_names, (
|
||||||
|
f"CI={ci}: filter has cards not in summary: {filter_names - summary_names}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test: Multi-color subset check (WB commander should include W, B, WB, colorless)
|
||||||
|
def test_multi_color_subset(self):
|
||||||
|
result = _query_for_colors(self.conn, ["W", "B"])
|
||||||
|
names = {c["name"] for c in result}
|
||||||
|
assert "Sol Ring" in names # colorless
|
||||||
|
assert "Swords to Plow" in names # W subset of WB
|
||||||
|
assert "Damnation" in names # B subset of WB
|
||||||
|
assert "Orzhov Signet" in names # WB subset of WB
|
||||||
|
assert "Counterspell" not in names # U not in WB
|
||||||
|
assert "Lightning Bolt" not in names # R not in WB
|
||||||
|
assert "Atraxa" not in names # WUBG not subset of WB
|
||||||
Reference in new issue
Block a user