Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
✨ 주요 기능: - 완전한 데이터베이스 스키마 분석 및 자동 마이그레이션 시스템 - 44개 테이블 완전 지원 (운영 서버 43개 + 1개 추가) - 누락된 테이블/컬럼 자동 감지 및 생성 🔧 해결된 스키마 문제: - users.status 컬럼 누락 → 자동 추가 - files 테이블 4개 컬럼 누락 → 자동 추가 - materials 테이블 22개 컬럼 누락 → 자동 추가 - support_details, purchase_requests, purchase_request_items 테이블 누락 → 자동 생성 - material_purchase_tracking.description, purchase_status 컬럼 누락 → 자동 추가 🚀 자동화 도구: - schema_analyzer.py: 코드와 DB 스키마 비교 분석 - auto_migrator.py: 자동 마이그레이션 실행 - docker_migrator.py: Docker 환경용 간편 마이그레이션 - schema_monitor.py: 실시간 스키마 모니터링 📋 리비전 관리 시스템: - 8개 카테고리별 리비전 페이지 구현 - PIPE Cutting Plan 관리 시스템 - PIPE Issue Management 시스템 - 완전한 리비전 비교 및 추적 기능 🎯 사용법: docker exec tk-mp-backend python3 scripts/docker_migrator.py 앞으로 스키마 문제가 발생하면 위 명령 하나로 자동 해결!
362 lines
13 KiB
Python
362 lines
13 KiB
Python
#!/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()
|