1148 lines
37 KiB
TypeScript
1148 lines
37 KiB
TypeScript
/**
|
|
* 다중 커넥션 쿼리 실행 서비스
|
|
* 외부 데이터베이스 커넥션을 통한 CRUD 작업 지원
|
|
* 자기 자신 테이블 작업을 위한 안전장치 포함
|
|
*/
|
|
|
|
import { ExternalDbConnectionService } from "./externalDbConnectionService";
|
|
import { TableManagementService } from "./tableManagementService";
|
|
import { ExternalDbConnection, ApiResponse } from "../types/externalDbTypes";
|
|
import { ColumnTypeInfo, TableInfo } from "../types/tableManagement";
|
|
import prisma from "../config/database";
|
|
import { logger } from "../utils/logger";
|
|
|
|
// 🔧 Prisma 클라이언트 중복 생성 방지 - 기존 인스턴스 재사용
|
|
|
|
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<string, any>
|
|
): Promise<Record<string, any>[]> {
|
|
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<string, any>
|
|
): Promise<any> {
|
|
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 쿼리 구성 (DB 타입별 처리)
|
|
const columns = Object.keys(data);
|
|
let values = Object.values(data);
|
|
|
|
// Oracle의 경우 테이블 스키마 확인 및 데이터 타입 변환 처리
|
|
if (connection.db_type?.toLowerCase() === "oracle") {
|
|
try {
|
|
// Oracle 테이블 스키마 조회
|
|
const schemaQuery = `
|
|
SELECT COLUMN_NAME, DATA_TYPE, NULLABLE, DATA_DEFAULT
|
|
FROM USER_TAB_COLUMNS
|
|
WHERE TABLE_NAME = UPPER('${tableName}')
|
|
ORDER BY COLUMN_ID
|
|
`;
|
|
|
|
logger.info(`🔍 Oracle 테이블 스키마 조회: ${schemaQuery}`);
|
|
|
|
const schemaResult = await ExternalDbConnectionService.executeQuery(
|
|
connectionId,
|
|
schemaQuery
|
|
);
|
|
|
|
if (schemaResult.success && schemaResult.data) {
|
|
logger.info(`📋 Oracle 테이블 ${tableName} 스키마:`);
|
|
schemaResult.data.forEach((col: any) => {
|
|
logger.info(
|
|
` - ${col.COLUMN_NAME}: ${col.DATA_TYPE}, NULL: ${col.NULLABLE}, DEFAULT: ${col.DATA_DEFAULT || "None"}`
|
|
);
|
|
});
|
|
|
|
// 필수 컬럼 중 누락된 컬럼이 있는지 확인 (기본값이 없는 NOT NULL 컬럼만)
|
|
const providedColumns = columns.map((col) => col.toUpperCase());
|
|
const missingRequiredColumns = schemaResult.data.filter(
|
|
(schemaCol: any) =>
|
|
schemaCol.NULLABLE === "N" &&
|
|
!schemaCol.DATA_DEFAULT &&
|
|
!providedColumns.includes(schemaCol.COLUMN_NAME)
|
|
);
|
|
|
|
if (missingRequiredColumns.length > 0) {
|
|
const missingNames = missingRequiredColumns.map(
|
|
(col: any) => col.COLUMN_NAME
|
|
);
|
|
logger.error(`❌ 필수 컬럼 누락: ${missingNames.join(", ")}`);
|
|
throw new Error(
|
|
`필수 컬럼이 누락되었습니다: ${missingNames.join(", ")}`
|
|
);
|
|
}
|
|
|
|
logger.info(
|
|
`✅ 스키마 검증 통과: 모든 필수 컬럼이 제공되었거나 기본값이 있습니다.`
|
|
);
|
|
}
|
|
} catch (schemaError) {
|
|
logger.warn(`⚠️ 스키마 조회 실패 (계속 진행): ${schemaError}`);
|
|
}
|
|
|
|
values = values.map((value) => {
|
|
// null이나 undefined는 그대로 유지
|
|
if (value === null || value === undefined) {
|
|
return value;
|
|
}
|
|
|
|
// 숫자로 변환 가능한 문자열은 숫자로 변환
|
|
if (typeof value === "string" && value.trim() !== "") {
|
|
const numValue = Number(value);
|
|
if (!isNaN(numValue)) {
|
|
logger.info(
|
|
`🔄 Oracle 데이터 타입 변환: "${value}" (string) → ${numValue} (number)`
|
|
);
|
|
return numValue;
|
|
}
|
|
}
|
|
|
|
return value;
|
|
});
|
|
}
|
|
|
|
let query: string;
|
|
let queryParams: any[];
|
|
const dbType = connection.db_type?.toLowerCase() || "postgresql";
|
|
|
|
switch (dbType) {
|
|
case "oracle":
|
|
// Oracle: :1, :2 스타일 바인딩 사용, RETURNING 미지원
|
|
const oraclePlaceholders = values
|
|
.map((_, index) => `:${index + 1}`)
|
|
.join(", ");
|
|
query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${oraclePlaceholders})`;
|
|
queryParams = values;
|
|
logger.info(`🔍 Oracle INSERT 상세 정보:`);
|
|
logger.info(` - 테이블: ${tableName}`);
|
|
logger.info(` - 컬럼: ${JSON.stringify(columns)}`);
|
|
logger.info(` - 값: ${JSON.stringify(values)}`);
|
|
logger.info(` - 쿼리: ${query}`);
|
|
logger.info(` - 파라미터: ${JSON.stringify(queryParams)}`);
|
|
logger.info(
|
|
` - 데이터 타입: ${JSON.stringify(values.map((v) => typeof v))}`
|
|
);
|
|
break;
|
|
|
|
case "mysql":
|
|
case "mariadb":
|
|
// MySQL/MariaDB: ? 스타일 바인딩 사용, RETURNING 미지원
|
|
const mysqlPlaceholders = values.map(() => "?").join(", ");
|
|
query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${mysqlPlaceholders})`;
|
|
queryParams = values;
|
|
logger.info(`MySQL/MariaDB INSERT 쿼리:`, {
|
|
query,
|
|
params: queryParams,
|
|
});
|
|
break;
|
|
|
|
case "sqlserver":
|
|
case "mssql":
|
|
// SQL Server: @param1, @param2 스타일 바인딩 사용
|
|
const sqlServerPlaceholders = values
|
|
.map((_, index) => `@param${index + 1}`)
|
|
.join(", ");
|
|
query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${sqlServerPlaceholders})`;
|
|
queryParams = values;
|
|
logger.info(`SQL Server INSERT 쿼리:`, {
|
|
query,
|
|
params: queryParams,
|
|
});
|
|
break;
|
|
|
|
case "sqlite":
|
|
// SQLite: ? 스타일 바인딩 사용, RETURNING 지원 (3.35.0+)
|
|
const sqlitePlaceholders = values.map(() => "?").join(", ");
|
|
query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${sqlitePlaceholders}) RETURNING *`;
|
|
queryParams = values;
|
|
logger.info(`SQLite INSERT 쿼리:`, { query, params: queryParams });
|
|
break;
|
|
|
|
case "postgresql":
|
|
default:
|
|
// PostgreSQL: $1, $2 스타일 바인딩 사용, RETURNING 지원
|
|
const pgPlaceholders = values
|
|
.map((_, index) => `$${index + 1}`)
|
|
.join(", ");
|
|
query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${pgPlaceholders}) RETURNING *`;
|
|
queryParams = values;
|
|
logger.info(`PostgreSQL INSERT 쿼리:`, {
|
|
query,
|
|
params: queryParams,
|
|
});
|
|
break;
|
|
}
|
|
|
|
// 외부 DB에서 쿼리 실행
|
|
const result = await ExternalDbConnectionService.executeQuery(
|
|
connectionId,
|
|
query,
|
|
queryParams
|
|
);
|
|
|
|
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<string, any>,
|
|
conditions: Record<string, any>
|
|
): Promise<any> {
|
|
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<string, any>,
|
|
maxDeleteCount: number = 100
|
|
): Promise<any> {
|
|
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<MultiConnectionTableInfo[]> {
|
|
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 getBatchTablesWithColumns(
|
|
connectionId: number
|
|
): Promise<
|
|
{ tableName: string; displayName?: string; columnCount: number }[]
|
|
> {
|
|
try {
|
|
logger.info(`배치 테이블 정보 조회 시작: connectionId=${connectionId}`);
|
|
|
|
// connectionId가 0이면 메인 DB
|
|
if (connectionId === 0) {
|
|
console.log("🔍 메인 DB 배치 테이블 정보 조회");
|
|
|
|
// 메인 DB의 모든 테이블과 각 테이블의 컬럼 수 조회
|
|
const tables = await this.tableManagementService.getTableList();
|
|
|
|
const result = await Promise.all(
|
|
tables.map(async (table) => {
|
|
try {
|
|
const columnsResult =
|
|
await this.tableManagementService.getColumnList(
|
|
table.tableName,
|
|
1,
|
|
1000
|
|
);
|
|
|
|
return {
|
|
tableName: table.tableName,
|
|
displayName: table.displayName,
|
|
columnCount: columnsResult.columns.length,
|
|
};
|
|
} catch (error) {
|
|
logger.warn(
|
|
`메인 DB 테이블 ${table.tableName} 컬럼 수 조회 실패:`,
|
|
error
|
|
);
|
|
return {
|
|
tableName: table.tableName,
|
|
displayName: table.displayName,
|
|
columnCount: 0,
|
|
};
|
|
}
|
|
})
|
|
);
|
|
|
|
logger.info(`✅ 메인 DB 배치 조회 완료: ${result.length}개 테이블`);
|
|
return result;
|
|
}
|
|
|
|
// 외부 DB 연결 정보 가져오기
|
|
const connectionResult =
|
|
await ExternalDbConnectionService.getConnectionById(connectionId);
|
|
if (!connectionResult.success || !connectionResult.data) {
|
|
throw new Error(`커넥션을 찾을 수 없습니다: ${connectionId}`);
|
|
}
|
|
const connection = connectionResult.data;
|
|
|
|
console.log(
|
|
`🔍 외부 DB 배치 테이블 정보 조회: connectionId=${connectionId}`
|
|
);
|
|
|
|
// 외부 DB의 테이블 목록 먼저 조회
|
|
const tablesResult =
|
|
await ExternalDbConnectionService.getTables(connectionId);
|
|
|
|
if (!tablesResult.success || !tablesResult.data) {
|
|
throw new Error("외부 DB 테이블 목록 조회 실패");
|
|
}
|
|
|
|
const tableNames = tablesResult.data;
|
|
|
|
// 🔧 각 테이블의 컬럼 수를 순차적으로 조회 (타임아웃 방지)
|
|
const result = [];
|
|
logger.info(
|
|
`📊 외부 DB 테이블 컬럼 조회 시작: ${tableNames.length}개 테이블`
|
|
);
|
|
|
|
for (let i = 0; i < tableNames.length; i++) {
|
|
const tableInfo = tableNames[i];
|
|
const tableName = tableInfo.table_name;
|
|
|
|
try {
|
|
logger.info(
|
|
`📋 테이블 ${i + 1}/${tableNames.length}: ${tableName} 컬럼 조회 중...`
|
|
);
|
|
|
|
// 🔧 타임아웃과 재시도 로직 추가
|
|
let columnsResult: ApiResponse<any[]> | undefined;
|
|
let retryCount = 0;
|
|
const maxRetries = 2;
|
|
|
|
while (retryCount <= maxRetries) {
|
|
try {
|
|
columnsResult = (await Promise.race([
|
|
ExternalDbConnectionService.getTableColumns(
|
|
connectionId,
|
|
tableName
|
|
),
|
|
new Promise<ApiResponse<any[]>>((_, reject) =>
|
|
setTimeout(
|
|
() => reject(new Error("컬럼 조회 타임아웃 (15초)")),
|
|
15000
|
|
)
|
|
),
|
|
])) as ApiResponse<any[]>;
|
|
break; // 성공하면 루프 종료
|
|
} catch (attemptError) {
|
|
retryCount++;
|
|
if (retryCount > maxRetries) {
|
|
throw attemptError; // 최대 재시도 후 에러 throw
|
|
}
|
|
logger.warn(
|
|
`⚠️ 테이블 ${tableName} 컬럼 조회 실패 (${retryCount}/${maxRetries}), 재시도 중...`
|
|
);
|
|
await new Promise((resolve) => setTimeout(resolve, 1000)); // 1초 대기 후 재시도
|
|
}
|
|
}
|
|
|
|
const columnCount =
|
|
columnsResult &&
|
|
columnsResult.success &&
|
|
Array.isArray(columnsResult.data)
|
|
? columnsResult.data.length
|
|
: 0;
|
|
|
|
result.push({
|
|
tableName,
|
|
displayName: tableName, // 외부 DB는 일반적으로 displayName이 없음
|
|
columnCount,
|
|
});
|
|
|
|
logger.info(`✅ 테이블 ${tableName}: ${columnCount}개 컬럼`);
|
|
} catch (error) {
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : String(error);
|
|
logger.warn(
|
|
`❌ 외부 DB 테이블 ${tableName} 컬럼 수 조회 최종 실패: ${errorMessage}`
|
|
);
|
|
result.push({
|
|
tableName,
|
|
displayName: tableName,
|
|
columnCount: 0, // 실패한 경우 0으로 설정
|
|
});
|
|
}
|
|
|
|
// 🔧 연결 부하 방지를 위한 약간의 지연
|
|
if (i < tableNames.length - 1) {
|
|
await new Promise((resolve) => setTimeout(resolve, 100)); // 100ms 지연
|
|
}
|
|
}
|
|
|
|
logger.info(`✅ 외부 DB 배치 조회 완료: ${result.length}개 테이블`);
|
|
return result;
|
|
} catch (error) {
|
|
logger.error(
|
|
`배치 테이블 정보 조회 실패: connectionId=${connectionId}, error=${
|
|
error instanceof Error ? error.message : error
|
|
}`
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 커넥션별 컬럼 정보 조회
|
|
*/
|
|
async getColumnsFromConnection(
|
|
connectionId: number,
|
|
tableName: string
|
|
): Promise<ColumnInfo[]> {
|
|
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}개`
|
|
);
|
|
|
|
// 디버깅: inputType이 'code'인 컬럼들 확인
|
|
const codeColumns = columnsResult.columns.filter(
|
|
(col) => col.inputType === "code"
|
|
);
|
|
console.log(
|
|
"🔍 메인 DB 코드 타입 컬럼들:",
|
|
codeColumns.map((col) => ({
|
|
columnName: col.columnName,
|
|
inputType: col.inputType,
|
|
webType: col.webType,
|
|
codeCategory: col.codeCategory,
|
|
}))
|
|
);
|
|
|
|
const mappedColumns = 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
|
|
inputType: column.inputType || "direct", // column_labels의 input_type 추가
|
|
codeCategory: column.codeCategory, // 코드 카테고리 정보 추가
|
|
isNullable: column.isNullable === "Y",
|
|
isPrimaryKey: column.isPrimaryKey || false,
|
|
defaultValue: column.defaultValue,
|
|
maxLength: column.maxLength,
|
|
description: column.description,
|
|
connectionId: 0, // 메인 DB 구분용
|
|
}));
|
|
|
|
// 디버깅: 매핑된 컬럼 정보 확인
|
|
console.log(
|
|
"🔍 매핑된 컬럼 정보 샘플:",
|
|
mappedColumns.slice(0, 3).map((col) => ({
|
|
columnName: col.columnName,
|
|
inputType: col.inputType,
|
|
webType: col.webType,
|
|
connectionId: col.connectionId,
|
|
}))
|
|
);
|
|
|
|
// status 컬럼 특별 확인
|
|
const statusColumn = mappedColumns.find(
|
|
(col) => col.columnName === "status"
|
|
);
|
|
if (statusColumn) {
|
|
console.log("🔍 status 컬럼 상세 정보:", statusColumn);
|
|
}
|
|
|
|
return mappedColumns;
|
|
}
|
|
|
|
// 외부 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),
|
|
inputType: "direct", // 외부 DB는 항상 direct (코드 타입 없음)
|
|
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,
|
|
connectionId: connectionId, // 외부 DB 구분용
|
|
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<ValidationResult> {
|
|
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<string, any>,
|
|
conditions?: Record<string, any>
|
|
): Promise<any> {
|
|
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";
|
|
}
|
|
}
|