feat(saju-lab): db.py — saju_records 3 컬럼 추가 (fortune_scores/lucky/monthly_flow) + 4 마이그레이션 테스트
This commit is contained in:
@@ -46,6 +46,17 @@ def init_db() -> None:
|
|||||||
CREATE INDEX IF NOT EXISTS idx_saju_created
|
CREATE INDEX IF NOT EXISTS idx_saju_created
|
||||||
ON saju_records(created_at DESC)
|
ON saju_records(created_at DESC)
|
||||||
""")
|
""")
|
||||||
|
# 신규 컬럼 ALTER (idempotent — 이미 있으면 OperationalError로 skip)
|
||||||
|
for col in (
|
||||||
|
"fortune_scores_json TEXT",
|
||||||
|
"lucky_json TEXT",
|
||||||
|
"monthly_flow_json TEXT",
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
conn.execute(f"ALTER TABLE saju_records ADD COLUMN {col}")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS compat_records (
|
CREATE TABLE IF NOT EXISTS compat_records (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -76,8 +87,9 @@ def save_saju_record(data: Dict[str, Any]) -> int:
|
|||||||
"""INSERT INTO saju_records
|
"""INSERT INTO saju_records
|
||||||
(birth_year, birth_month, birth_day, birth_hour, gender, calendar_type,
|
(birth_year, birth_month, birth_day, birth_hour, gender, calendar_type,
|
||||||
saju_data, analysis_data, daeun_data, interpretation_json,
|
saju_data, analysis_data, daeun_data, interpretation_json,
|
||||||
model, tokens_in, tokens_out, cost_usd, latency_ms, reroll_count)
|
model, tokens_in, tokens_out, cost_usd, latency_ms, reroll_count,
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
fortune_scores_json, lucky_json, monthly_flow_json)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
(
|
(
|
||||||
data["birth_year"], data["birth_month"], data["birth_day"],
|
data["birth_year"], data["birth_month"], data["birth_day"],
|
||||||
data.get("birth_hour"), data["gender"], data.get("calendar_type", "solar"),
|
data.get("birth_hour"), data["gender"], data.get("calendar_type", "solar"),
|
||||||
@@ -89,6 +101,9 @@ def save_saju_record(data: Dict[str, Any]) -> int:
|
|||||||
data.get("tokens_in", 0), data.get("tokens_out", 0),
|
data.get("tokens_in", 0), data.get("tokens_out", 0),
|
||||||
data.get("cost_usd", 0.0), data.get("latency_ms", 0),
|
data.get("cost_usd", 0.0), data.get("latency_ms", 0),
|
||||||
data.get("reroll_count", 0),
|
data.get("reroll_count", 0),
|
||||||
|
json.dumps(data["fortune_scores_json"], ensure_ascii=False) if data.get("fortune_scores_json") else None,
|
||||||
|
json.dumps(data["lucky_json"], ensure_ascii=False) if data.get("lucky_json") else None,
|
||||||
|
json.dumps(data["monthly_flow_json"], ensure_ascii=False) if data.get("monthly_flow_json") else None,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return int(cur.lastrowid)
|
return int(cur.lastrowid)
|
||||||
@@ -138,18 +153,29 @@ def delete_saju_record(record_id: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _saju_row_to_dict(r) -> Dict[str, Any]:
|
def _saju_row_to_dict(r) -> Dict[str, Any]:
|
||||||
|
def _safe_json(val):
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": r["id"],
|
"id": r["id"],
|
||||||
"created_at": r["created_at"],
|
"created_at": r["created_at"],
|
||||||
"birth_year": r["birth_year"], "birth_month": r["birth_month"], "birth_day": r["birth_day"],
|
"birth_year": r["birth_year"], "birth_month": r["birth_month"], "birth_day": r["birth_day"],
|
||||||
"birth_hour": r["birth_hour"], "gender": r["gender"], "calendar_type": r["calendar_type"],
|
"birth_hour": r["birth_hour"], "gender": r["gender"], "calendar_type": r["calendar_type"],
|
||||||
"saju_data": json.loads(r["saju_data"]) if r["saju_data"] else None,
|
"saju_data": _safe_json(r["saju_data"]),
|
||||||
"analysis_data": json.loads(r["analysis_data"]) if r["analysis_data"] else None,
|
"analysis_data": _safe_json(r["analysis_data"]),
|
||||||
"daeun_data": json.loads(r["daeun_data"]) if r["daeun_data"] else None,
|
"daeun_data": _safe_json(r["daeun_data"]),
|
||||||
"interpretation_json": json.loads(r["interpretation_json"]) if r["interpretation_json"] else None,
|
"interpretation_json": _safe_json(r["interpretation_json"]),
|
||||||
"model": r["model"], "tokens_in": r["tokens_in"], "tokens_out": r["tokens_out"],
|
"model": r["model"], "tokens_in": r["tokens_in"], "tokens_out": r["tokens_out"],
|
||||||
"cost_usd": r["cost_usd"], "latency_ms": r["latency_ms"], "reroll_count": r["reroll_count"],
|
"cost_usd": r["cost_usd"], "latency_ms": r["latency_ms"], "reroll_count": r["reroll_count"],
|
||||||
"favorite": int(r["favorite"]), "memo": r["memo"],
|
"favorite": int(r["favorite"]), "memo": r["memo"],
|
||||||
|
"fortune_scores": _safe_json(r["fortune_scores_json"]) if "fortune_scores_json" in r.keys() else None,
|
||||||
|
"lucky": _safe_json(r["lucky_json"]) if "lucky_json" in r.keys() else None,
|
||||||
|
"monthly_flow": _safe_json(r["monthly_flow_json"]) if "monthly_flow_json" in r.keys() else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
66
saju-lab/tests/test_db_migration.py
Normal file
66
saju-lab/tests/test_db_migration.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import sqlite3
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import db as db_module
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def fresh_db(monkeypatch, tmp_path):
|
||||||
|
db_file = tmp_path / "test_saju.db"
|
||||||
|
monkeypatch.setattr(db_module, "DB_PATH", str(db_file))
|
||||||
|
yield
|
||||||
|
try:
|
||||||
|
if db_file.exists():
|
||||||
|
db_file.unlink()
|
||||||
|
except PermissionError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_columns_exist():
|
||||||
|
db_module.init_db()
|
||||||
|
conn = sqlite3.connect(db_module.DB_PATH)
|
||||||
|
cols = [row[1] for row in conn.execute("PRAGMA table_info(saju_records)").fetchall()]
|
||||||
|
conn.close()
|
||||||
|
assert "fortune_scores_json" in cols
|
||||||
|
assert "lucky_json" in cols
|
||||||
|
assert "monthly_flow_json" in cols
|
||||||
|
|
||||||
|
|
||||||
|
def test_idempotent_init():
|
||||||
|
db_module.init_db()
|
||||||
|
db_module.init_db() # 두 번째 호출 — ALTER TABLE이 OperationalError 캐치
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_and_get_with_new_fields():
|
||||||
|
db_module.init_db()
|
||||||
|
rid = db_module.save_saju_record({
|
||||||
|
"birth_year": 1990, "birth_month": 5, "birth_day": 15, "birth_hour": 14,
|
||||||
|
"gender": "male", "calendar_type": "solar",
|
||||||
|
"saju_data": {"day_stem": "辛"},
|
||||||
|
"analysis_data": {"element_balance": {"金": 3.0}},
|
||||||
|
"daeun_data": [{"age": 10}],
|
||||||
|
"interpretation_json": {"items": []},
|
||||||
|
"model": "claude-sonnet-4-6",
|
||||||
|
"tokens_in": 100, "tokens_out": 200, "cost_usd": 0.005,
|
||||||
|
"fortune_scores_json": {"wealth": 80, "romance": 60, "social": 70, "career": 75, "overall": 73},
|
||||||
|
"lucky_json": {"color": ["청록"], "number": 5, "direction": "동쪽", "good_signs": ["S1"], "warnings": []},
|
||||||
|
"monthly_flow_json": [{"month": 1, "stem": "壬", "branch": "寅", "score": 65, "label": "변동"}],
|
||||||
|
})
|
||||||
|
row = db_module.get_saju_record(rid)
|
||||||
|
assert row["fortune_scores"]["wealth"] == 80
|
||||||
|
assert row["lucky"]["number"] == 5
|
||||||
|
assert row["monthly_flow"][0]["month"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_without_new_fields_backwards_compat():
|
||||||
|
db_module.init_db()
|
||||||
|
rid = db_module.save_saju_record({
|
||||||
|
"birth_year": 1990, "birth_month": 5, "birth_day": 15, "birth_hour": None,
|
||||||
|
"gender": "male",
|
||||||
|
"saju_data": {}, "analysis_data": {}, "daeun_data": [],
|
||||||
|
"model": "x",
|
||||||
|
})
|
||||||
|
row = db_module.get_saju_record(rid)
|
||||||
|
assert row["fortune_scores"] is None
|
||||||
|
assert row["lucky"] is None
|
||||||
|
assert row["monthly_flow"] is None
|
||||||
Reference in New Issue
Block a user