diff --git a/app/core/database.py b/app/core/database.py index cbf4052..c8f6552 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -57,12 +57,12 @@ def _parse_migration_files(migrations_dir: Path) -> list[tuple[int, str, Path]]: def _validate_sql_content(name: str, sql: str) -> None: """migration SQL에 BEGIN/COMMIT이 포함되어 있으면 에러 (외부 트랜잭션 깨짐 방지)""" - # 주석(-- ...) 라인 제거 후 검사 - lines = [ - line for line in sql.splitlines() - if not line.strip().startswith("--") - ] - stripped = "\n".join(lines).upper() + # 주석(전체 줄 + 인라인 `-- ...`) 제거 후 검사. ★인라인 주석을 안 지우면 설명 주석의 + # 'commit/begin' 단어(예 365_scan_jobs 의 `-- commit 시 documents.title 로 전파`)를 + # 트랜잭션 제어문으로 false-positive 로 잡아 fresh DB/DR 부트스트랩이 깨진다(verification + # 실측 2026-06). 줄별로 `--` 이후를 잘라 주석 텍스트를 검사에서 제외. + cleaned = [re.sub(r"--.*$", "", line) for line in sql.splitlines()] + stripped = "\n".join(cleaned).upper() for keyword in ("BEGIN", "COMMIT", "ROLLBACK"): # 단어 경계로 매칭 (예: BEGIN_SOMETHING은 제외) if re.search(rf"\b{keyword}\b", stripped): @@ -70,6 +70,13 @@ def _validate_sql_content(name: str, sql: str) -> None: f"migration {name}에 {keyword} 포함됨 — " f"migration SQL에는 트랜잭션 제어문을 넣지 마세요" ) + # schema_migrations 수정 금지 (runner 가 스탬프 관리) — 주석 제외(stripped) 검사. + # (구: _run_migrations 의 raw `"schema_migrations" in sql.lower()` 가 주석 미제외라 + # 365 의 '-- ... schema_migrations 를 건드리지 않음' 주석을 false-positive 로 잡았음.) + if "SCHEMA_MIGRATIONS" in stripped: + raise RuntimeError( + f"Migration {name} must not modify schema_migrations table" + ) # R1: baseline 스냅샷이 대표하는 마지막 마이그레이션 버전 (이하 버전은 baseline 에 포함). @@ -167,16 +174,15 @@ async def _run_migrations(conn) -> None: for version, name, path in pending: sql = path.read_text(encoding="utf-8") - _validate_sql_content(name, sql) - if "schema_migrations" in sql.lower(): - raise ValueError( - f"Migration {name} must not modify schema_migrations table" - ) + _validate_sql_content(name, sql) # BEGIN/COMMIT + schema_migrations 검사(주석 제외) logger.info(f"[migration] {name} 실행 중...") - # raw driver SQL 사용 — text() 의 :name bind parameter 해석으로 - # SQL 주석/literal 에 콜론이 들어가면 InvalidRequestError 발생. - # exec_driver_sql 은 SQL 을 driver(asyncpg) 에 그대로 전달. - await conn.exec_driver_sql(sql) + # raw asyncpg simple 프로토콜로 실행 — baseline 적재(_load_baseline_if_fresh)와 동일. + # ★exec_driver_sql 은 prepared 프로토콜이라 multi-statement 불허("cannot insert multiple + # commands into a prepared statement"). 365_scan_jobs 처럼 테이블+시드+인덱스를 한 파일에 + # 담은 마이그(컨벤션상 1-statement 권장이나 이미 prod 적재)도 fresh DB/DR replay 되게 + # simple execute 사용. text() :name 콜론-binding 이슈도 동일하게 회피(raw 전달). + raw = await conn.get_raw_connection() + await raw.driver_connection.execute(sql) await conn.execute( text("INSERT INTO schema_migrations (version, name) VALUES (:v, :n)"), {"v": version, "n": name},