#!/usr/bin/env python3 """ 스키마 분석기 - 코드와 DB 스키마를 비교하여 누락된 테이블/컬럼을 찾는 도구 """ import os import sys import re import json from typing import Dict, List, Set, Tuple from datetime import datetime import psycopg2 from psycopg2.extras import RealDictCursor # 프로젝트 루트를 Python 경로에 추가 sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) class SchemaAnalyzer: def __init__(self): self.db_config = { 'host': 'localhost', 'port': 5432, 'database': 'tk_mp_bom', 'user': 'tkmp_user', 'password': 'tkmp_password' } self.code_tables = {} self.db_tables = {} self.analysis_result = { 'timestamp': datetime.now().isoformat(), 'missing_tables': [], 'missing_columns': [], 'extra_tables': [], 'schema_issues': [] } def analyze_code_models(self) -> Dict[str, Dict]: """코드에서 SQLAlchemy 모델을 분석하여 테이블 구조 추출""" print("📋 코드 모델 분석 중...") model_files = [ 'backend/app/models.py', 'backend/app/auth/models.py' ] for file_path in model_files: if os.path.exists(file_path): self._parse_model_file(file_path) print(f"✅ 코드에서 {len(self.code_tables)}개 테이블 발견") return self.code_tables def _parse_model_file(self, file_path: str): """모델 파일을 파싱하여 테이블 정보 추출""" with open(file_path, 'r', encoding='utf-8') as f: content = f.read() # 클래스 정의 찾기 class_pattern = r'class\s+(\w+)\(Base\):(.*?)(?=class\s+\w+\(Base\):|$)' classes = re.findall(class_pattern, content, re.DOTALL) for class_name, class_content in classes: table_info = self._parse_class_content(class_name, class_content) if table_info: table_name = table_info['table_name'] self.code_tables[table_name] = table_info def _parse_class_content(self, class_name: str, content: str) -> Dict: """클래스 내용을 파싱하여 테이블 정보 추출""" # __tablename__ 찾기 tablename_match = re.search(r'__tablename__\s*=\s*["\']([^"\']+)["\']', content) if not tablename_match: return None table_name = tablename_match.group(1) # 컬럼 정의 찾기 column_pattern = r'(\w+)\s*=\s*Column\((.*?)\)' columns = {} for match in re.finditer(column_pattern, content, re.DOTALL): column_name = match.group(1) column_def = match.group(2) # 컬럼 타입과 속성 파싱 column_info = self._parse_column_definition(column_def) columns[column_name] = column_info return { 'class_name': class_name, 'table_name': table_name, 'columns': columns, 'file_path': None # 나중에 설정 } def _parse_column_definition(self, column_def: str) -> Dict: """컬럼 정의를 파싱하여 타입과 속성 추출""" # 기본 타입 매핑 type_mapping = { 'Integer': 'integer', 'String': 'character varying', 'Text': 'text', 'Boolean': 'boolean', 'DateTime': 'timestamp without time zone', 'Numeric': 'numeric', 'JSON': 'json' } # 타입 추출 type_match = re.search(r'(Integer|String|Text|Boolean|DateTime|Numeric|JSON)', column_def) column_type = 'unknown' if type_match: sqlalchemy_type = type_match.group(1) column_type = type_mapping.get(sqlalchemy_type, sqlalchemy_type.lower()) # 속성 추출 nullable = 'nullable=False' not in column_def primary_key = 'primary_key=True' in column_def unique = 'unique=True' in column_def default = None # default 값 추출 default_match = re.search(r'default=([^,)]+)', column_def) if default_match: default = default_match.group(1).strip() return { 'type': column_type, 'nullable': nullable, 'primary_key': primary_key, 'unique': unique, 'default': default } def analyze_db_schema(self) -> Dict[str, Dict]: """실제 DB 스키마 분석""" print("🗄️ DB 스키마 분석 중...") try: conn = psycopg2.connect(**self.db_config) cursor = conn.cursor(cursor_factory=RealDictCursor) # 테이블 목록 조회 cursor.execute(""" SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename """) tables = [row['tablename'] for row in cursor.fetchall()] # 각 테이블의 컬럼 정보 조회 for table_name in tables: cursor.execute(""" SELECT column_name, data_type, is_nullable, column_default, character_maximum_length FROM information_schema.columns WHERE table_name = %s AND table_schema = 'public' ORDER BY ordinal_position """, (table_name,)) columns = {} for row in cursor.fetchall(): columns[row['column_name']] = { 'type': row['data_type'], 'nullable': row['is_nullable'] == 'YES', 'default': row['column_default'], 'max_length': row['character_maximum_length'] } self.db_tables[table_name] = { 'table_name': table_name, 'columns': columns } cursor.close() conn.close() print(f"✅ DB에서 {len(self.db_tables)}개 테이블 발견") return self.db_tables except Exception as e: print(f"❌ DB 연결 실패: {e}") return {} def compare_schemas(self) -> Dict: """코드와 DB 스키마 비교""" print("🔍 스키마 비교 중...") code_table_names = set(self.code_tables.keys()) db_table_names = set(self.db_tables.keys()) # 누락된 테이블 (코드에는 있지만 DB에는 없음) missing_tables = code_table_names - db_table_names self.analysis_result['missing_tables'] = list(missing_tables) # 추가 테이블 (DB에는 있지만 코드에는 없음) extra_tables = db_table_names - code_table_names self.analysis_result['extra_tables'] = list(extra_tables) # 공통 테이블의 컬럼 비교 common_tables = code_table_names & db_table_names for table_name in common_tables: missing_columns = self._compare_table_columns(table_name) if missing_columns: self.analysis_result['missing_columns'].extend(missing_columns) return self.analysis_result def _compare_table_columns(self, table_name: str) -> List[Dict]: """특정 테이블의 컬럼 비교""" code_columns = set(self.code_tables[table_name]['columns'].keys()) db_columns = set(self.db_tables[table_name]['columns'].keys()) missing_columns = [] # 누락된 컬럼들 for column_name in code_columns - db_columns: column_info = self.code_tables[table_name]['columns'][column_name] missing_columns.append({ 'table': table_name, 'column': column_name, 'type': column_info['type'], 'nullable': column_info['nullable'], 'default': column_info['default'] }) return missing_columns def generate_migration_sql(self) -> str: """누락된 스키마에 대한 마이그레이션 SQL 생성""" sql_statements = [] sql_statements.append("-- 자동 생성된 마이그레이션 SQL") sql_statements.append(f"-- 생성 시간: {datetime.now()}") sql_statements.append("") # 누락된 테이블 생성 for table_name in self.analysis_result['missing_tables']: if table_name in self.code_tables: create_sql = self._generate_create_table_sql(table_name) sql_statements.append(create_sql) sql_statements.append("") # 누락된 컬럼 추가 for missing_col in self.analysis_result['missing_columns']: alter_sql = self._generate_add_column_sql(missing_col) sql_statements.append(alter_sql) return "\n".join(sql_statements) def _generate_create_table_sql(self, table_name: str) -> str: """테이블 생성 SQL 생성""" table_info = self.code_tables[table_name] columns = [] for col_name, col_info in table_info['columns'].items(): col_def = f" {col_name} {col_info['type']}" if not col_info['nullable']: col_def += " NOT NULL" if col_info['default']: col_def += f" DEFAULT {col_info['default']}" if col_info['primary_key']: col_def += " PRIMARY KEY" columns.append(col_def) return f"CREATE TABLE {table_name} (\n" + ",\n".join(columns) + "\n);" def _generate_add_column_sql(self, missing_col: Dict) -> str: """컬럼 추가 SQL 생성""" sql = f"ALTER TABLE {missing_col['table']} ADD COLUMN {missing_col['column']} {missing_col['type']}" if missing_col['default']: sql += f" DEFAULT {missing_col['default']}" if not missing_col['nullable']: sql += " NOT NULL" return sql + ";" def save_analysis_report(self, filename: str = "schema_analysis_report.json"): """분석 결과를 JSON 파일로 저장""" with open(filename, 'w', encoding='utf-8') as f: json.dump(self.analysis_result, f, indent=2, ensure_ascii=False) print(f"📄 분석 보고서 저장: {filename}") def print_summary(self): """분석 결과 요약 출력""" print("\n" + "="*60) print("📊 스키마 분석 결과 요약") print("="*60) print(f"🔍 분석 시간: {self.analysis_result['timestamp']}") print(f"📋 코드 테이블: {len(self.code_tables)}개") print(f"🗄️ DB 테이블: {len(self.db_tables)}개") if self.analysis_result['missing_tables']: print(f"\n❌ 누락된 테이블 ({len(self.analysis_result['missing_tables'])}개):") for table in self.analysis_result['missing_tables']: print(f" - {table}") if self.analysis_result['missing_columns']: print(f"\n❌ 누락된 컬럼 ({len(self.analysis_result['missing_columns'])}개):") for col in self.analysis_result['missing_columns']: print(f" - {col['table']}.{col['column']} ({col['type']})") if self.analysis_result['extra_tables']: print(f"\n➕ 추가 테이블 ({len(self.analysis_result['extra_tables'])}개):") for table in self.analysis_result['extra_tables']: print(f" - {table}") if not any([self.analysis_result['missing_tables'], self.analysis_result['missing_columns']]): print("\n✅ 스키마가 완전히 동기화되어 있습니다!") def main(): print("🚀 TK-MP-Project 스키마 분석기 시작") analyzer = SchemaAnalyzer() # 1. 코드 모델 분석 analyzer.analyze_code_models() # 2. DB 스키마 분석 analyzer.analyze_db_schema() # 3. 스키마 비교 analyzer.compare_schemas() # 4. 결과 출력 analyzer.print_summary() # 5. 마이그레이션 SQL 생성 if analyzer.analysis_result['missing_tables'] or analyzer.analysis_result['missing_columns']: migration_sql = analyzer.generate_migration_sql() with open('migration.sql', 'w', encoding='utf-8') as f: f.write(migration_sql) print(f"\n📝 마이그레이션 SQL 생성: migration.sql") print("다음 명령으로 실행 가능:") print("docker exec tk-mp-postgres psql -U tkmp_user -d tk_mp_bom -f /path/to/migration.sql") # 6. 분석 보고서 저장 analyzer.save_analysis_report() return analyzer.analysis_result if __name__ == "__main__": main()