- 지역 이야기(가요·인물·연표·엽서·퀴즈) 생성 경로: story_service · grounding/story · section_prompts. 지금까지 만들 자리가 없어 시안에만 손으로 넣은 3만 자였다 - 발행본 섹션: ItinerarySection · Carousel 레일 자동재생(use-rail-autoplay) · Festival · LocalGuide · Weather · Gallery · Header/Footer - 목업 payload 를 payloads-mockup/ 으로 분리 — 발행 대상과 섞이지 않게 - DB 새 구조 후속: site_payload · local_content_crud 조인 정리 · 테스트 - 마이그레이션 주석 축약: 9개 파일 합계 주석 비율 48% → 25%. 실측과 밟은 함정만 남기고 논증은 커밋 메시지로 옮겼다 검증: site·frontend 빌드 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
111 lines
5.2 KiB
Python
111 lines
5.2 KiB
Python
"""init.sql ↔ ORM 모델 정합성.
|
|
|
|
스키마 정의가 두 곳(마이그레이션 SQL / SQLAlchemy 모델)에 있으므로 둘이 어긋나면
|
|
"테스트는 통과하는데 실서버에서 컬럼이 없는" 상황이 난다. DB 없이 파일만 비교해 그걸 잡는다.
|
|
"""
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from common.database.model.models import MAIN_BASE
|
|
|
|
# parents[3] = 레포 루트 (tests → backend → solution → 루트).
|
|
# ★ 백엔드가 solution/ 안으로 들어가면서 한 칸 깊어졌다 — 폴더를 옮기면 여기부터 깨진다.
|
|
_INIT_SQL = Path(__file__).resolve().parents[3] / "postgres-init" / "init-data" / "init.sql"
|
|
|
|
_CREATE_TABLE_RE = re.compile(
|
|
r"CREATE TABLE IF NOT EXISTS\s+(?P<schema>\w+)\.(?P<table>\w+)\s*\((?P<body>.*?)\n\);",
|
|
re.S | re.I,
|
|
)
|
|
|
|
|
|
def _parse_init_sql() -> dict:
|
|
"""init.sql → {"schema.table": {컬럼명, ...}}"""
|
|
sql = _INIT_SQL.read_text(encoding="utf-8")
|
|
out = {}
|
|
for m in _CREATE_TABLE_RE.finditer(sql):
|
|
columns = set()
|
|
for line in m.group("body").splitlines():
|
|
line = line.strip()
|
|
if not line or line.startswith("--"):
|
|
continue
|
|
name = line.split()[0]
|
|
if name.upper() in ("PRIMARY", "UNIQUE", "CONSTRAINT", "FOREIGN", "CHECK"):
|
|
continue
|
|
columns.add(name)
|
|
out[f"{m.group('schema')}.{m.group('table')}"] = columns
|
|
return out
|
|
|
|
|
|
# init.sql 에는 있고 ORM 모델은 없는 표. 마이그레이션 대장은 scripts/migrate.py 가 소유하고
|
|
# 애플리케이션 코드가 읽지 않는다 — 모델을 만들면 도메인 표처럼 보인다.
|
|
_NOT_ORM = {"public.schema_migrations"}
|
|
|
|
|
|
def _model_tables() -> set:
|
|
"""ORM 모델 → {"public.table", ...}
|
|
|
|
★ 모델은 스키마를 적지 않는다(`Table.schema is None`) — 2026-09-09 에 도메인 스키마를
|
|
걷어내고 public 한 벌로 폈기 때문이다(migrations/0005). init.sql 은 `public.` 을
|
|
명시하므로 여기서 같은 모양으로 맞춰 준다. `t.schema` 를 그대로 쓰면 "None.users" 가
|
|
되어 **모든 표가 누락으로 잡힌다** — 실제로 그렇게 이 테스트가 통째로 빨개졌다.
|
|
"""
|
|
return {f"{t.schema or 'public'}.{t.name}" for t in MAIN_BASE.metadata.sorted_tables}
|
|
|
|
|
|
def test_init_sql_is_readable():
|
|
"""검증: init.sql 을 찾고 파싱할 수 있는지.
|
|
기대결과: 파일이 존재하고 CREATE TABLE 이 1개 이상 파싱된다."""
|
|
assert _INIT_SQL.exists(), f"init.sql 이 없다: {_INIT_SQL}"
|
|
assert _parse_init_sql(), "init.sql 에서 CREATE TABLE 을 하나도 파싱하지 못했다"
|
|
|
|
|
|
def test_every_model_table_exists_in_init_sql():
|
|
"""검증: ORM 모델의 모든 테이블이 init.sql 에도 있는지.
|
|
기대결과: 누락 없음 — 모델만 추가하고 마이그레이션을 안 쓴 경우를 잡는다."""
|
|
sql_tables = set(_parse_init_sql())
|
|
model_tables = _model_tables()
|
|
missing = sorted(model_tables - sql_tables)
|
|
assert not missing, f"init.sql 에 없는 모델 테이블: {missing}"
|
|
|
|
|
|
def test_every_init_sql_table_has_a_model():
|
|
"""검증: init.sql 의 모든 테이블에 ORM 모델이 있는지.
|
|
기대결과: 누락 없음 — 마이그레이션만 쓰고 모델을 안 만든 경우를 잡는다."""
|
|
sql_tables = set(_parse_init_sql()) - _NOT_ORM
|
|
model_tables = _model_tables()
|
|
missing = sorted(sql_tables - model_tables)
|
|
assert not missing, f"ORM 모델이 없는 init.sql 테이블: {missing}"
|
|
|
|
|
|
def test_columns_match_between_model_and_init_sql():
|
|
"""검증: 테이블마다 컬럼 집합이 양쪽에서 같은지.
|
|
기대결과: 완전 일치 — 한쪽에만 추가된 컬럼을 잡는다."""
|
|
sql_tables = _parse_init_sql()
|
|
problems = []
|
|
compared = 0
|
|
for table in MAIN_BASE.metadata.sorted_tables:
|
|
name = f"{table.schema or 'public'}.{table.name}"
|
|
if name not in sql_tables:
|
|
continue
|
|
compared += 1
|
|
model_cols = {c.name for c in table.columns}
|
|
sql_cols = sql_tables[name]
|
|
if model_cols != sql_cols:
|
|
problems.append(
|
|
f"{name}: 모델에만 {sorted(model_cols - sql_cols)} / init.sql 에만 {sorted(sql_cols - model_cols)}"
|
|
)
|
|
assert not problems, "컬럼 불일치:\n" + "\n".join(problems)
|
|
# ★ 한 표도 못 찾으면 이 테스트는 아무것도 검사하지 않고 통과한다. 실제로 그랬다 —
|
|
# 이름을 "None.users" 로 만들어 전부 건너뛰었고, 그동안 init.sql 이 조용히 어긋났다.
|
|
assert compared == len(MAIN_BASE.metadata.sorted_tables), (
|
|
f"init.sql 에서 {compared}/{len(MAIN_BASE.metadata.sorted_tables)} 개만 찾았다 — 이름 규칙이 어긋났다"
|
|
)
|
|
|
|
|
|
def test_every_table_has_soft_delete_columns():
|
|
"""검증: 모든 테이블이 공통 컬럼(created_at·updated_at·deleted)을 갖는지.
|
|
기대결과: MainTableMixin 을 빠뜨린 모델이 없다 — 소프트 삭제 전제가 깨지면 유니크 부분 인덱스도 깨진다."""
|
|
for table in MAIN_BASE.metadata.sorted_tables:
|
|
cols = {c.name for c in table.columns}
|
|
assert {"created_at", "updated_at", "deleted"} <= cols, f"{table.schema}.{table.name}: 공통 컬럼 누락"
|