/** * 다중 커넥션 쿼리 실행 서비스 * 외부 데이터베이스 커넥션을 통한 CRUD 작업 지원 * 자기 자신 테이블 작업을 위한 안전장치 포함 */ import { ExternalDbConnectionService } from "./externalDbConnectionService"; import { TableManagementService } from "./tableManagementService"; import { ExternalDbConnection } from "../types/externalDbTypes"; import { ColumnTypeInfo, TableInfo } from "../types/tableManagement"; import { PrismaClient } from "@prisma/client"; import { logger } from "../utils/logger"; const prisma = new PrismaClient(); export interface ValidationResult { isValid: boolean; error?: string; warnings?: string[]; } export interface ColumnInfo { columnName: string; displayName: string; dataType: string; dbType: string; webType: string; isNullable: boolean; isPrimaryKey: boolean; defaultValue?: string; maxLength?: number; description?: string; } export interface MultiConnectionTableInfo { tableName: string; displayName?: string; columnCount: number; connectionId: number; connectionName: string; dbType: string; } export class MultiConnectionQueryService { private tableManagementService: TableManagementService; constructor() { this.tableManagementService = new TableManagementService(); } /** * 소스 커넥션에서 데이터 조회 */ async fetchDataFromConnection( connectionId: number, tableName: string, conditions?: Record ): Promise[]> { try { logger.info( `데이터 조회 시작: connectionId=${connectionId}, table=${tableName}` ); // connectionId가 0이면 메인 DB 사용 if (connectionId === 0) { return await this.executeOnMainDatabase( "select", tableName, undefined, conditions ); } // 외부 DB 연결 정보 가져오기 const connectionResult = await ExternalDbConnectionService.getConnectionById(connectionId); if (!connectionResult.success || !connectionResult.data) { throw new Error(`커넥션을 찾을 수 없습니다: ${connectionId}`); } const connection = connectionResult.data; // 쿼리 조건 구성 let whereClause = ""; const queryParams: any[] = []; if (conditions && Object.keys(conditions).length > 0) { const conditionParts: string[] = []; let paramIndex = 1; Object.entries(conditions).forEach(([key, value]) => { conditionParts.push(`${key} = $${paramIndex}`); queryParams.push(value); paramIndex++; }); whereClause = `WHERE ${conditionParts.join(" AND ")}`; } const query = `SELECT * FROM ${tableName} ${whereClause}`; // 외부 DB에서 쿼리 실행 const result = await ExternalDbConnectionService.executeQuery( connectionId, query ); if (!result.success || !result.data) { throw new Error(result.message || "쿼리 실행 실패"); } logger.info(`데이터 조회 완료: ${result.data.length}건`); return result.data; } catch (error) { logger.error(`데이터 조회 실패: ${error}`); throw new Error( `데이터 조회 실패: ${error instanceof Error ? error.message : error}` ); } } /** * 대상 커넥션에 데이터 삽입 */ async insertDataToConnection( connectionId: number, tableName: string, data: Record ): Promise { try { logger.info( `데이터 삽입 시작: connectionId=${connectionId}, table=${tableName}` ); // connectionId가 0이면 메인 DB 사용 if (connectionId === 0) { return await this.executeOnMainDatabase("insert", tableName, data); } // 외부 DB 연결 정보 가져오기 const connectionResult = await ExternalDbConnectionService.getConnectionById(connectionId); if (!connectionResult.success || !connectionResult.data) { throw new Error(`커넥션을 찾을 수 없습니다: ${connectionId}`); } const connection = connectionResult.data; // INSERT 쿼리 구성 const columns = Object.keys(data); const values = Object.values(data); const placeholders = values.map((_, index) => `$${index + 1}`).join(", "); const query = ` INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders}) RETURNING * `; // 외부 DB에서 쿼리 실행 const result = await ExternalDbConnectionService.executeQuery( connectionId, query ); if (!result.success || !result.data) { throw new Error(result.message || "데이터 삽입 실패"); } logger.info(`데이터 삽입 완료`); return result.data[0] || result.data; } catch (error) { logger.error(`데이터 삽입 실패: ${error}`); throw new Error( `데이터 삽입 실패: ${error instanceof Error ? error.message : error}` ); } } /** * 🆕 대상 커넥션에 데이터 업데이트 */ async updateDataToConnection( connectionId: number, tableName: string, data: Record, conditions: Record ): Promise { try { logger.info( `데이터 업데이트 시작: connectionId=${connectionId}, table=${tableName}` ); // 자기 자신 테이블 작업 검증 if (connectionId === 0) { const validationResult = await this.validateSelfTableOperation( tableName, "update", [conditions] ); if (!validationResult.isValid) { throw new Error( `자기 자신 테이블 업데이트 검증 실패: ${validationResult.error}` ); } } // connectionId가 0이면 메인 DB 사용 if (connectionId === 0) { return await this.executeOnMainDatabase( "update", tableName, data, conditions ); } // 외부 DB 연결 정보 가져오기 const connectionResult = await ExternalDbConnectionService.getConnectionById(connectionId); if (!connectionResult.success || !connectionResult.data) { throw new Error(`커넥션을 찾을 수 없습니다: ${connectionId}`); } const connection = connectionResult.data; // UPDATE 쿼리 구성 const setClause = Object.keys(data) .map((key, index) => `${key} = $${index + 1}`) .join(", "); const whereClause = Object.keys(conditions) .map( (key, index) => `${key} = $${Object.keys(data).length + index + 1}` ) .join(" AND "); const query = ` UPDATE ${tableName} SET ${setClause} WHERE ${whereClause} RETURNING * `; const queryParams = [ ...Object.values(data), ...Object.values(conditions), ]; // 외부 DB에서 쿼리 실행 const result = await ExternalDbConnectionService.executeQuery( connectionId, query ); if (!result.success || !result.data) { throw new Error(result.message || "데이터 업데이트 실패"); } logger.info(`데이터 업데이트 완료: ${result.data.length}건`); return result.data; } catch (error) { logger.error(`데이터 업데이트 실패: ${error}`); throw new Error( `데이터 업데이트 실패: ${error instanceof Error ? error.message : error}` ); } } /** * 🆕 대상 커넥션에서 데이터 삭제 */ async deleteDataFromConnection( connectionId: number, tableName: string, conditions: Record, maxDeleteCount: number = 100 ): Promise { try { logger.info( `데이터 삭제 시작: connectionId=${connectionId}, table=${tableName}` ); // 자기 자신 테이블 작업 검증 if (connectionId === 0) { const validationResult = await this.validateSelfTableOperation( tableName, "delete", [conditions] ); if (!validationResult.isValid) { throw new Error( `자기 자신 테이블 삭제 검증 실패: ${validationResult.error}` ); } } // WHERE 조건 필수 체크 if (!conditions || Object.keys(conditions).length === 0) { throw new Error("DELETE 작업에는 반드시 WHERE 조건이 필요합니다."); } // connectionId가 0이면 메인 DB 사용 if (connectionId === 0) { return await this.executeOnMainDatabase( "delete", tableName, undefined, conditions ); } // 외부 DB 연결 정보 가져오기 const connectionResult = await ExternalDbConnectionService.getConnectionById(connectionId); if (!connectionResult.success || !connectionResult.data) { throw new Error(`커넥션을 찾을 수 없습니다: ${connectionId}`); } const connection = connectionResult.data; // 먼저 삭제 대상 개수 확인 (안전장치) const countQuery = ` SELECT COUNT(*) as count FROM ${tableName} WHERE ${Object.keys(conditions) .map((key, index) => `${key} = $${index + 1}`) .join(" AND ")} `; const countResult = await ExternalDbConnectionService.executeQuery( connectionId, countQuery ); if (!countResult.success || !countResult.data) { throw new Error(countResult.message || "삭제 대상 개수 조회 실패"); } const deleteCount = parseInt(countResult.data[0]?.count || "0"); if (deleteCount > maxDeleteCount) { throw new Error( `삭제 대상이 ${deleteCount}건으로 최대 허용 개수(${maxDeleteCount})를 초과합니다.` ); } // DELETE 쿼리 실행 const deleteQuery = ` DELETE FROM ${tableName} WHERE ${Object.keys(conditions) .map((key, index) => `${key} = $${index + 1}`) .join(" AND ")} RETURNING * `; const result = await ExternalDbConnectionService.executeQuery( connectionId, deleteQuery ); if (!result.success || !result.data) { throw new Error(result.message || "데이터 삭제 실패"); } logger.info(`데이터 삭제 완료: ${result.data.length}건`); return result.data; } catch (error) { logger.error(`데이터 삭제 실패: ${error}`); throw new Error( `데이터 삭제 실패: ${error instanceof Error ? error.message : error}` ); } } /** * 커넥션별 테이블 목록 조회 */ async getTablesFromConnection( connectionId: number ): Promise { try { logger.info(`테이블 목록 조회 시작: connectionId=${connectionId}`); // connectionId가 0이면 메인 DB의 테이블 목록 반환 if (connectionId === 0) { const tables = await this.tableManagementService.getTableList(); return tables.map((table) => ({ tableName: table.tableName, displayName: table.displayName || table.tableName, // 라벨이 있으면 라벨 사용, 없으면 테이블명 columnCount: table.columnCount, connectionId: 0, connectionName: "메인 데이터베이스", dbType: "postgresql", })); } // 외부 DB 연결 정보 가져오기 const connectionResult = await ExternalDbConnectionService.getConnectionById(connectionId); if (!connectionResult.success || !connectionResult.data) { throw new Error(`커넥션을 찾을 수 없습니다: ${connectionId}`); } const connection = connectionResult.data; // 외부 DB의 테이블 목록 조회 const tablesResult = await ExternalDbConnectionService.getTables(connectionId); if (!tablesResult.success || !tablesResult.data) { throw new Error(tablesResult.message || "테이블 조회 실패"); } const tables = tablesResult.data; // 성능 최적화: 컬럼 개수는 실제 필요할 때만 조회하도록 변경 return tables.map((table: any) => ({ tableName: table.table_name, displayName: table.table_comment || table.table_name, // 라벨(comment)이 있으면 라벨 사용, 없으면 테이블명 columnCount: 0, // 성능을 위해 0으로 설정, 필요시 별도 API로 조회 connectionId: connectionId, connectionName: connection.connection_name, dbType: connection.db_type, })); } catch (error) { logger.error(`테이블 목록 조회 실패: ${error}`); throw new Error( `테이블 목록 조회 실패: ${error instanceof Error ? error.message : error}` ); } } /** * 커넥션별 컬럼 정보 조회 */ async getColumnsFromConnection( connectionId: number, tableName: string ): Promise { try { logger.info( `컬럼 정보 조회 시작: connectionId=${connectionId}, table=${tableName}` ); // connectionId가 0이면 메인 DB의 컬럼 정보 반환 if (connectionId === 0) { console.log(`🔍 메인 DB 컬럼 정보 조회 시작: ${tableName}`); const columnsResult = await this.tableManagementService.getColumnList( tableName, 1, 1000 ); console.log( `✅ 메인 DB 컬럼 조회 성공: ${columnsResult.columns.length}개` ); return columnsResult.columns.map((column) => ({ columnName: column.columnName, displayName: column.displayName || column.columnName, // 라벨이 있으면 라벨 사용, 없으면 컬럼명 dataType: column.dataType, dbType: column.dataType, // dataType을 dbType으로 사용 webType: column.webType || "text", // webType 사용, 기본값 text isNullable: column.isNullable === "Y", isPrimaryKey: column.isPrimaryKey || false, defaultValue: column.defaultValue, maxLength: column.maxLength, description: column.description, })); } // 외부 DB 연결 정보 가져오기 const connectionResult = await ExternalDbConnectionService.getConnectionById(connectionId); if (!connectionResult.success || !connectionResult.data) { throw new Error(`커넥션을 찾을 수 없습니다: ${connectionId}`); } const connection = connectionResult.data; // 외부 DB의 컬럼 정보 조회 console.log( `🔍 외부 DB 컬럼 정보 조회 시작: connectionId=${connectionId}, table=${tableName}` ); const columnsResult = await ExternalDbConnectionService.getTableColumns( connectionId, tableName ); if (!columnsResult.success || !columnsResult.data) { console.error(`❌ 외부 DB 컬럼 조회 실패: ${columnsResult.message}`); throw new Error(columnsResult.message || "컬럼 조회 실패"); } const columns = columnsResult.data; console.log(`✅ 외부 DB 컬럼 조회 성공: ${columns.length}개`); // MSSQL 컬럼 데이터 구조 디버깅 if (columns.length > 0) { console.log( `🔍 MSSQL 컬럼 데이터 구조 분석:`, JSON.stringify(columns[0], null, 2) ); console.log(`🔍 모든 컬럼 키들:`, Object.keys(columns[0])); } return columns.map((column: any) => { // MSSQL과 PostgreSQL 데이터 타입 필드명이 다를 수 있음 // MSSQL: name, type, description (MSSQLConnector에서 alias로 지정) // PostgreSQL: column_name, data_type, column_comment const dataType = column.type || // MSSQL (MSSQLConnector alias) column.data_type || // PostgreSQL column.DATA_TYPE || column.Type || column.dataType || column.column_type || column.COLUMN_TYPE || "unknown"; const columnName = column.name || // MSSQL (MSSQLConnector alias) column.column_name || // PostgreSQL column.COLUMN_NAME || column.Name || column.columnName || column.COLUMN_NAME; const columnComment = column.description || // MSSQL (MSSQLConnector alias) column.column_comment || // PostgreSQL column.COLUMN_COMMENT || column.Description || column.comment; console.log(`🔍 컬럼 매핑: ${columnName} - 타입: ${dataType}`); return { columnName: columnName, displayName: columnComment || columnName, // 라벨(comment)이 있으면 라벨 사용, 없으면 컬럼명 dataType: dataType, dbType: dataType, webType: this.mapDataTypeToWebType(dataType), isNullable: column.nullable === "YES" || // MSSQL (MSSQLConnector alias) column.is_nullable === "YES" || // PostgreSQL column.IS_NULLABLE === "YES" || column.Nullable === true, isPrimaryKey: column.is_primary_key || column.IS_PRIMARY_KEY || false, defaultValue: column.default_value || // MSSQL (MSSQLConnector alias) column.column_default || // PostgreSQL column.COLUMN_DEFAULT, maxLength: column.max_length || // MSSQL (MSSQLConnector alias) column.character_maximum_length || // PostgreSQL column.CHARACTER_MAXIMUM_LENGTH, description: columnComment, }; }); } catch (error) { logger.error(`컬럼 정보 조회 실패: ${error}`); throw new Error( `컬럼 정보 조회 실패: ${error instanceof Error ? error.message : error}` ); } } /** * 🆕 자기 자신 테이블 작업 전용 검증 */ async validateSelfTableOperation( tableName: string, operation: "update" | "delete", conditions: any[] ): Promise { try { logger.info( `자기 자신 테이블 작업 검증: table=${tableName}, operation=${operation}` ); const warnings: string[] = []; // 1. 기본 조건 체크 if (!conditions || conditions.length === 0) { return { isValid: false, error: `자기 자신 테이블 ${operation.toUpperCase()} 작업에는 반드시 조건이 필요합니다.`, }; } // 2. DELETE 작업에 대한 추가 검증 if (operation === "delete") { // 부정 조건 체크 const hasNegativeConditions = conditions.some((condition) => { const conditionStr = JSON.stringify(condition).toLowerCase(); return ( conditionStr.includes("!=") || conditionStr.includes("not in") || conditionStr.includes("not exists") ); }); if (hasNegativeConditions) { return { isValid: false, error: "자기 자신 테이블 삭제 시 부정 조건(!=, NOT IN, NOT EXISTS)은 위험합니다.", }; } // 조건 개수 체크 if (conditions.length < 2) { warnings.push( "자기 자신 테이블 삭제 시 WHERE 조건을 2개 이상 설정하는 것을 권장합니다." ); } } // 3. UPDATE 작업에 대한 추가 검증 if (operation === "update") { warnings.push("자기 자신 테이블 업데이트 시 무한 루프에 주의하세요."); } return { isValid: true, warnings: warnings.length > 0 ? warnings : undefined, }; } catch (error) { logger.error(`자기 자신 테이블 작업 검증 실패: ${error}`); return { isValid: false, error: `검증 과정에서 오류가 발생했습니다: ${error instanceof Error ? error.message : error}`, }; } } /** * 🆕 메인 DB 작업 (connectionId = 0인 경우) */ async executeOnMainDatabase( operation: "select" | "insert" | "update" | "delete", tableName: string, data?: Record, conditions?: Record ): Promise { try { logger.info( `메인 DB 작업 실행: operation=${operation}, table=${tableName}` ); switch (operation) { case "select": let query = `SELECT * FROM ${tableName}`; const queryParams: any[] = []; if (conditions && Object.keys(conditions).length > 0) { const whereClause = Object.keys(conditions) .map((key, index) => `${key} = $${index + 1}`) .join(" AND "); query += ` WHERE ${whereClause}`; queryParams.push(...Object.values(conditions)); } return await prisma.$queryRawUnsafe(query, ...queryParams); case "insert": if (!data) throw new Error("INSERT 작업에는 데이터가 필요합니다."); const insertColumns = Object.keys(data); const insertValues = Object.values(data); const insertPlaceholders = insertValues .map((_, index) => `$${index + 1}`) .join(", "); const insertQuery = ` INSERT INTO ${tableName} (${insertColumns.join(", ")}) VALUES (${insertPlaceholders}) RETURNING * `; const insertResult = await prisma.$queryRawUnsafe( insertQuery, ...insertValues ); return Array.isArray(insertResult) ? insertResult[0] : insertResult; case "update": if (!data) throw new Error("UPDATE 작업에는 데이터가 필요합니다."); if (!conditions) throw new Error("UPDATE 작업에는 조건이 필요합니다."); const setClause = Object.keys(data) .map((key, index) => `${key} = $${index + 1}`) .join(", "); const updateWhereClause = Object.keys(conditions) .map( (key, index) => `${key} = $${Object.keys(data).length + index + 1}` ) .join(" AND "); const updateQuery = ` UPDATE ${tableName} SET ${setClause} WHERE ${updateWhereClause} RETURNING * `; const updateParams = [ ...Object.values(data), ...Object.values(conditions), ]; return await prisma.$queryRawUnsafe(updateQuery, ...updateParams); case "delete": if (!conditions) throw new Error("DELETE 작업에는 조건이 필요합니다."); const deleteWhereClause = Object.keys(conditions) .map((key, index) => `${key} = $${index + 1}`) .join(" AND "); const deleteQuery = ` DELETE FROM ${tableName} WHERE ${deleteWhereClause} RETURNING * `; return await prisma.$queryRawUnsafe( deleteQuery, ...Object.values(conditions) ); default: throw new Error(`지원하지 않는 작업입니다: ${operation}`); } } catch (error) { logger.error(`메인 DB 작업 실패: ${error}`); throw new Error( `메인 DB 작업 실패: ${error instanceof Error ? error.message : error}` ); } } /** * 데이터 타입을 웹 타입으로 매핑 */ private mapDataTypeToWebType(dataType: string | undefined | null): string { // 안전한 타입 검사 if (!dataType || typeof dataType !== "string") { console.warn(`⚠️ 잘못된 데이터 타입: ${dataType}, 기본값 'text' 사용`); return "text"; } const lowerType = dataType.toLowerCase(); // PostgreSQL & MSSQL 타입 매핑 if ( lowerType.includes("int") || lowerType.includes("serial") || lowerType.includes("bigint") ) { return "number"; } if ( lowerType.includes("decimal") || lowerType.includes("numeric") || lowerType.includes("float") || lowerType.includes("money") || lowerType.includes("real") ) { return "decimal"; } if (lowerType.includes("date") && !lowerType.includes("time")) { return "date"; } if ( lowerType.includes("timestamp") || lowerType.includes("datetime") || lowerType.includes("datetime2") ) { return "datetime"; } if (lowerType.includes("bool") || lowerType.includes("bit")) { return "boolean"; } if ( lowerType.includes("text") || lowerType.includes("clob") || lowerType.includes("ntext") ) { return "textarea"; } // MSSQL 특수 타입들 if ( lowerType.includes("varchar") || lowerType.includes("nvarchar") || lowerType.includes("char") ) { return "text"; } return "text"; } }