Files
TK-BOM-Project/backend/scripts/schema_analyzer.py
Hyungi Ahn 8f42a1054e
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

앞으로 스키마 문제가 발생하면 위 명령 하나로 자동 해결!
2025-10-21 10:34:45 +09:00

362 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()