Compare commits
No commits in common. "acfe282daea1aa8ccb39a675eb0604400931dc50" and "cb0058cd370a12d2e465cb5a8da12b60a54d9f2c" have entirely different histories.
acfe282dae
...
cb0058cd37
|
|
@ -31,7 +31,6 @@ import layoutRoutes from "./routes/layoutRoutes";
|
||||||
import dataRoutes from "./routes/dataRoutes";
|
import dataRoutes from "./routes/dataRoutes";
|
||||||
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
|
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
|
||||||
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
|
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
|
||||||
import multiConnectionRoutes from "./routes/multiConnectionRoutes";
|
|
||||||
import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes";
|
import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes";
|
||||||
import ddlRoutes from "./routes/ddlRoutes";
|
import ddlRoutes from "./routes/ddlRoutes";
|
||||||
import entityReferenceRoutes from "./routes/entityReferenceRoutes";
|
import entityReferenceRoutes from "./routes/entityReferenceRoutes";
|
||||||
|
|
@ -131,7 +130,6 @@ app.use("/api/screen", screenStandardRoutes);
|
||||||
app.use("/api/data", dataRoutes);
|
app.use("/api/data", dataRoutes);
|
||||||
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
|
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
|
||||||
app.use("/api/external-db-connections", externalDbConnectionRoutes);
|
app.use("/api/external-db-connections", externalDbConnectionRoutes);
|
||||||
app.use("/api/multi-connection", multiConnectionRoutes);
|
|
||||||
app.use("/api/db-type-categories", dbTypeCategoryRoutes);
|
app.use("/api/db-type-categories", dbTypeCategoryRoutes);
|
||||||
app.use("/api/ddl", ddlRoutes);
|
app.use("/api/ddl", ddlRoutes);
|
||||||
app.use("/api/entity-reference", entityReferenceRoutes);
|
app.use("/api/entity-reference", entityReferenceRoutes);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
import { Client } from "pg";
|
import { Client } from 'pg';
|
||||||
import {
|
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector';
|
||||||
DatabaseConnector,
|
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
|
||||||
ConnectionConfig,
|
|
||||||
QueryResult,
|
|
||||||
} from "../interfaces/DatabaseConnector";
|
|
||||||
import { ConnectionTestResult, TableInfo } from "../types/externalDbTypes";
|
|
||||||
|
|
||||||
export class PostgreSQLConnector implements DatabaseConnector {
|
export class PostgreSQLConnector implements DatabaseConnector {
|
||||||
private client: Client | null = null;
|
private client: Client | null = null;
|
||||||
|
|
@ -15,72 +11,37 @@ export class PostgreSQLConnector implements DatabaseConnector {
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect(): Promise<void> {
|
async connect(): Promise<void> {
|
||||||
// 기존 연결이 있다면 먼저 정리
|
if (this.client) {
|
||||||
await this.forceDisconnect();
|
await this.disconnect();
|
||||||
|
}
|
||||||
const clientConfig: any = {
|
const clientConfig: any = {
|
||||||
host: this.config.host,
|
host: this.config.host,
|
||||||
port: this.config.port,
|
port: this.config.port,
|
||||||
database: this.config.database,
|
database: this.config.database,
|
||||||
user: this.config.user,
|
user: this.config.user,
|
||||||
password: this.config.password,
|
password: this.config.password,
|
||||||
// 연결 안정성 개선 (더 보수적인 설정)
|
|
||||||
connectionTimeoutMillis: this.config.connectionTimeoutMillis || 15000,
|
|
||||||
query_timeout: this.config.queryTimeoutMillis || 20000,
|
|
||||||
keepAlive: false, // keepAlive 비활성화 (연결 문제 방지)
|
|
||||||
// SASL 인증 문제 방지
|
|
||||||
application_name: "PLM-ERP-System",
|
|
||||||
// 추가 안정성 설정
|
|
||||||
statement_timeout: 20000,
|
|
||||||
idle_in_transaction_session_timeout: 30000,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (this.config.connectionTimeoutMillis != null) {
|
||||||
|
clientConfig.connectionTimeoutMillis = this.config.connectionTimeoutMillis;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.config.queryTimeoutMillis != null) {
|
||||||
|
clientConfig.query_timeout = this.config.queryTimeoutMillis;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.config.ssl != null) {
|
if (this.config.ssl != null) {
|
||||||
clientConfig.ssl = this.config.ssl;
|
clientConfig.ssl = this.config.ssl;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.client = new Client(clientConfig);
|
this.client = new Client(clientConfig);
|
||||||
|
await this.client.connect();
|
||||||
// 연결 시 더 긴 타임아웃 설정
|
|
||||||
const connectPromise = this.client.connect();
|
|
||||||
const timeoutPromise = new Promise((_, reject) => {
|
|
||||||
setTimeout(() => reject(new Error("연결 타임아웃")), 20000);
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.race([connectPromise, timeoutPromise]);
|
|
||||||
console.log(
|
|
||||||
`✅ PostgreSQL 연결 성공: ${this.config.host}:${this.config.port}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 강제 연결 해제 메서드 추가
|
|
||||||
private async forceDisconnect(): Promise<void> {
|
|
||||||
if (this.client) {
|
|
||||||
try {
|
|
||||||
await this.client.end();
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("강제 연결 해제 중 오류 (무시):", error);
|
|
||||||
} finally {
|
|
||||||
this.client = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async disconnect(): Promise<void> {
|
async disconnect(): Promise<void> {
|
||||||
if (this.client) {
|
if (this.client) {
|
||||||
try {
|
await this.client.end();
|
||||||
const endPromise = this.client.end();
|
this.client = null;
|
||||||
const timeoutPromise = new Promise((_, reject) => {
|
|
||||||
setTimeout(() => reject(new Error("연결 해제 타임아웃")), 3000);
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.race([endPromise, timeoutPromise]);
|
|
||||||
console.log(`✅ PostgreSQL 연결 해제 성공`);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("연결 해제 중 오류:", error);
|
|
||||||
} finally {
|
|
||||||
this.client = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,9 +49,7 @@ export class PostgreSQLConnector implements DatabaseConnector {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
try {
|
try {
|
||||||
await this.connect();
|
await this.connect();
|
||||||
const result = await this.client!.query(
|
const result = await this.client!.query("SELECT version(), pg_database_size(current_database()) as size");
|
||||||
"SELECT version(), pg_database_size(current_database()) as size"
|
|
||||||
);
|
|
||||||
const responseTime = Date.now() - startTime;
|
const responseTime = Date.now() - startTime;
|
||||||
await this.disconnect();
|
await this.disconnect();
|
||||||
return {
|
return {
|
||||||
|
|
@ -99,9 +58,7 @@ export class PostgreSQLConnector implements DatabaseConnector {
|
||||||
details: {
|
details: {
|
||||||
response_time: responseTime,
|
response_time: responseTime,
|
||||||
server_version: result.rows[0]?.version || "알 수 없음",
|
server_version: result.rows[0]?.version || "알 수 없음",
|
||||||
database_size: this.formatBytes(
|
database_size: this.formatBytes(parseInt(result.rows[0]?.size || "0")),
|
||||||
parseInt(result.rows[0]?.size || "0")
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|
@ -134,28 +91,9 @@ export class PostgreSQLConnector implements DatabaseConnector {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTables(): Promise<TableInfo[]> {
|
async getTables(): Promise<TableInfo[]> {
|
||||||
let tempClient: Client | null = null;
|
|
||||||
try {
|
try {
|
||||||
console.log(
|
await this.connect();
|
||||||
`🔍 PostgreSQL 테이블 목록 조회 시작: ${this.config.host}:${this.config.port}`
|
const result = await this.client!.query(`
|
||||||
);
|
|
||||||
|
|
||||||
// 매번 새로운 연결 생성
|
|
||||||
const clientConfig: any = {
|
|
||||||
host: this.config.host,
|
|
||||||
port: this.config.port,
|
|
||||||
database: this.config.database,
|
|
||||||
user: this.config.user,
|
|
||||||
password: this.config.password,
|
|
||||||
connectionTimeoutMillis: 10000,
|
|
||||||
query_timeout: 15000,
|
|
||||||
application_name: "PLM-ERP-Tables",
|
|
||||||
};
|
|
||||||
|
|
||||||
tempClient = new Client(clientConfig);
|
|
||||||
await tempClient.connect();
|
|
||||||
|
|
||||||
const result = await tempClient.query(`
|
|
||||||
SELECT
|
SELECT
|
||||||
t.table_name,
|
t.table_name,
|
||||||
obj_description(quote_ident(t.table_name)::regclass::oid, 'pg_class') as table_description
|
obj_description(quote_ident(t.table_name)::regclass::oid, 'pg_class') as table_description
|
||||||
|
|
@ -164,81 +102,36 @@ export class PostgreSQLConnector implements DatabaseConnector {
|
||||||
AND t.table_type = 'BASE TABLE'
|
AND t.table_type = 'BASE TABLE'
|
||||||
ORDER BY t.table_name;
|
ORDER BY t.table_name;
|
||||||
`);
|
`);
|
||||||
|
await this.disconnect();
|
||||||
console.log(`✅ 테이블 목록 조회 성공: ${result.rows.length}개`);
|
|
||||||
return result.rows.map((row) => ({
|
return result.rows.map((row) => ({
|
||||||
table_name: row.table_name,
|
table_name: row.table_name,
|
||||||
description: row.table_description,
|
description: row.table_description,
|
||||||
columns: [], // Columns will be fetched by getColumns
|
columns: [], // Columns will be fetched by getColumns
|
||||||
}));
|
}));
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`❌ 테이블 목록 조회 실패:`, error.message);
|
await this.disconnect();
|
||||||
throw new Error(`PostgreSQL 테이블 목록 조회 실패: ${error.message}`);
|
throw new Error(`PostgreSQL 테이블 목록 조회 실패: ${error.message}`);
|
||||||
} finally {
|
|
||||||
if (tempClient) {
|
|
||||||
try {
|
|
||||||
await tempClient.end();
|
|
||||||
} catch (endError) {
|
|
||||||
console.warn("테이블 조회 연결 해제 중 오류:", endError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getColumns(tableName: string): Promise<any[]> {
|
async getColumns(tableName: string): Promise<any[]> {
|
||||||
let tempClient: Client | null = null;
|
|
||||||
try {
|
try {
|
||||||
console.log(
|
await this.connect();
|
||||||
`🔍 PostgreSQL 컬럼 정보 조회 시작: ${this.config.host}:${this.config.port}/${tableName}`
|
const result = await this.client!.query(`
|
||||||
);
|
|
||||||
|
|
||||||
// 매번 새로운 연결 생성
|
|
||||||
const clientConfig: any = {
|
|
||||||
host: this.config.host,
|
|
||||||
port: this.config.port,
|
|
||||||
database: this.config.database,
|
|
||||||
user: this.config.user,
|
|
||||||
password: this.config.password,
|
|
||||||
connectionTimeoutMillis: 10000,
|
|
||||||
query_timeout: 15000,
|
|
||||||
application_name: "PLM-ERP-Columns",
|
|
||||||
};
|
|
||||||
|
|
||||||
tempClient = new Client(clientConfig);
|
|
||||||
await tempClient.connect();
|
|
||||||
|
|
||||||
const result = await tempClient.query(
|
|
||||||
`
|
|
||||||
SELECT
|
SELECT
|
||||||
column_name,
|
column_name,
|
||||||
data_type,
|
data_type,
|
||||||
is_nullable,
|
is_nullable,
|
||||||
column_default,
|
column_default
|
||||||
col_description(c.oid, a.attnum) as column_comment
|
FROM information_schema.columns
|
||||||
FROM information_schema.columns isc
|
WHERE table_schema = 'public' AND table_name = $1
|
||||||
LEFT JOIN pg_class c ON c.relname = isc.table_name
|
ORDER BY ordinal_position;
|
||||||
LEFT JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = isc.column_name
|
`, [tableName]);
|
||||||
WHERE isc.table_schema = 'public' AND isc.table_name = $1
|
await this.disconnect();
|
||||||
ORDER BY isc.ordinal_position;
|
|
||||||
`,
|
|
||||||
[tableName]
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`✅ 컬럼 정보 조회 성공: ${tableName} - ${result.rows.length}개`
|
|
||||||
);
|
|
||||||
return result.rows;
|
return result.rows;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`❌ 컬럼 정보 조회 실패: ${tableName} -`, error.message);
|
await this.disconnect();
|
||||||
throw new Error(`PostgreSQL 컬럼 정보 조회 실패: ${error.message}`);
|
throw new Error(`PostgreSQL 컬럼 정보 조회 실패: ${error.message}`);
|
||||||
} finally {
|
|
||||||
if (tempClient) {
|
|
||||||
try {
|
|
||||||
await tempClient.end();
|
|
||||||
} catch (endError) {
|
|
||||||
console.warn("컬럼 조회 연결 해제 중 오류:", endError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -249,4 +142,4 @@ export class PostgreSQLConnector implements DatabaseConnector {
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -108,8 +108,7 @@ router.get(
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const result =
|
const result = await ExternalDbConnectionService.getConnectionsGroupedByType(filter);
|
||||||
await ExternalDbConnectionService.getConnectionsGroupedByType(filter);
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
return res.status(200).json(result);
|
return res.status(200).json(result);
|
||||||
|
|
@ -121,7 +120,7 @@ router.get(
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "그룹화된 연결 목록 조회 중 오류가 발생했습니다.",
|
message: "그룹화된 연결 목록 조회 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -291,7 +290,7 @@ router.post(
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
|
|
||||||
if (isNaN(id)) {
|
if (isNaN(id)) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
|
|
@ -304,17 +303,10 @@ router.post(
|
||||||
}
|
}
|
||||||
|
|
||||||
// 테스트용 비밀번호가 제공된 경우 사용
|
// 테스트용 비밀번호가 제공된 경우 사용
|
||||||
const testData = req.body.password
|
const testData = req.body.password ? { password: req.body.password } : undefined;
|
||||||
? { password: req.body.password }
|
console.log(`🔍 [API] 연결테스트 요청 - ID: ${id}, 비밀번호 제공됨: ${!!req.body.password}`);
|
||||||
: undefined;
|
|
||||||
console.log(
|
const result = await ExternalDbConnectionService.testConnectionById(id, testData);
|
||||||
`🔍 [API] 연결테스트 요청 - ID: ${id}, 비밀번호 제공됨: ${!!req.body.password}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await ExternalDbConnectionService.testConnectionById(
|
|
||||||
id,
|
|
||||||
testData
|
|
||||||
);
|
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
success: result.success,
|
success: result.success,
|
||||||
|
|
@ -350,7 +342,7 @@ router.post(
|
||||||
if (!query?.trim()) {
|
if (!query?.trim()) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "쿼리가 입력되지 않았습니다.",
|
message: "쿼리가 입력되지 않았습니다."
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -361,7 +353,7 @@ router.post(
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "쿼리 실행 중 오류가 발생했습니다.",
|
message: "쿼리 실행 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -384,7 +376,7 @@ router.get(
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -401,106 +393,26 @@ router.get(
|
||||||
try {
|
try {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
const tableName = req.params.tableName;
|
const tableName = req.params.tableName;
|
||||||
|
|
||||||
if (!tableName) {
|
if (!tableName) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "테이블명이 입력되지 않았습니다.",
|
message: "테이블명이 입력되지 않았습니다."
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await ExternalDbConnectionService.getTableColumns(
|
const result = await ExternalDbConnectionService.getTableColumns(id, tableName);
|
||||||
id,
|
|
||||||
tableName
|
|
||||||
);
|
|
||||||
return res.json(result);
|
return res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("테이블 컬럼 조회 오류:", error);
|
console.error("테이블 컬럼 조회 오류:", error);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "테이블 컬럼 조회 중 오류가 발생했습니다.",
|
message: "테이블 컬럼 조회 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* 🆕 GET /api/external-db-connections/active
|
|
||||||
* 제어관리용 활성 커넥션 목록 조회 (현재 DB 포함)
|
|
||||||
*/
|
|
||||||
router.get(
|
|
||||||
"/control/active",
|
|
||||||
authenticateToken,
|
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
|
||||||
try {
|
|
||||||
// 활성 상태의 외부 커넥션 조회
|
|
||||||
const filter: ExternalDbConnectionFilter = {
|
|
||||||
is_active: "Y",
|
|
||||||
company_code: (req.query.company_code as string) || "*",
|
|
||||||
};
|
|
||||||
|
|
||||||
const externalConnections =
|
|
||||||
await ExternalDbConnectionService.getConnections(filter);
|
|
||||||
|
|
||||||
if (!externalConnections.success) {
|
|
||||||
return res.status(400).json(externalConnections);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 외부 커넥션들에 대해 연결 테스트 수행 (병렬 처리)
|
|
||||||
const testedConnections = await Promise.all(
|
|
||||||
(externalConnections.data || []).map(async (connection) => {
|
|
||||||
try {
|
|
||||||
const testResult =
|
|
||||||
await ExternalDbConnectionService.testConnectionById(
|
|
||||||
connection.id!
|
|
||||||
);
|
|
||||||
return testResult.success ? connection : null;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`커넥션 테스트 실패 (ID: ${connection.id}):`, error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// 테스트에 성공한 커넥션만 필터링
|
|
||||||
const validExternalConnections = testedConnections.filter(
|
|
||||||
(conn) => conn !== null
|
|
||||||
);
|
|
||||||
|
|
||||||
// 현재 메인 DB를 첫 번째로 추가
|
|
||||||
const mainDbConnection = {
|
|
||||||
id: 0,
|
|
||||||
connection_name: "메인 데이터베이스 (현재 시스템)",
|
|
||||||
description: "현재 시스템의 PostgreSQL 데이터베이스",
|
|
||||||
db_type: "postgresql",
|
|
||||||
host: "localhost",
|
|
||||||
port: 5432,
|
|
||||||
database_name: process.env.DB_NAME || "erp_database",
|
|
||||||
username: "system",
|
|
||||||
password: "***",
|
|
||||||
is_active: "Y",
|
|
||||||
company_code: "*",
|
|
||||||
created_date: new Date(),
|
|
||||||
updated_date: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const allConnections = [mainDbConnection, ...validExternalConnections];
|
|
||||||
|
|
||||||
return res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
data: allConnections,
|
|
||||||
message: "제어관리용 활성 커넥션 목록을 조회했습니다.",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("제어관리용 활성 커넥션 조회 오류:", error);
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "서버 내부 오류가 발생했습니다.",
|
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -1,367 +0,0 @@
|
||||||
/**
|
|
||||||
* 다중 커넥션 관리 API 라우트
|
|
||||||
* 제어관리에서 외부 DB와의 통합 작업을 위한 API
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Router, Response } from "express";
|
|
||||||
import { MultiConnectionQueryService } from "../services/multiConnectionQueryService";
|
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
|
||||||
import { AuthenticatedRequest } from "../types/auth";
|
|
||||||
import { logger } from "../utils/logger";
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
const multiConnectionService = new MultiConnectionQueryService();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/multi-connection/connections/:connectionId/tables
|
|
||||||
* 특정 커넥션의 테이블 목록 조회 (메인 DB 포함)
|
|
||||||
*/
|
|
||||||
router.get(
|
|
||||||
"/connections/:connectionId/tables",
|
|
||||||
authenticateToken,
|
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
|
||||||
try {
|
|
||||||
const connectionId = parseInt(req.params.connectionId);
|
|
||||||
|
|
||||||
if (isNaN(connectionId)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "유효하지 않은 커넥션 ID입니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`테이블 목록 조회 요청: connectionId=${connectionId}`);
|
|
||||||
|
|
||||||
const tables =
|
|
||||||
await multiConnectionService.getTablesFromConnection(connectionId);
|
|
||||||
|
|
||||||
return res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
data: tables,
|
|
||||||
message: `커넥션 ${connectionId}의 테이블 목록을 조회했습니다.`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`테이블 목록 조회 실패: ${error}`);
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/multi-connection/connections/:connectionId/tables/:tableName/columns
|
|
||||||
* 특정 커넥션의 테이블 컬럼 정보 조회 (메인 DB 포함)
|
|
||||||
*/
|
|
||||||
router.get(
|
|
||||||
"/connections/:connectionId/tables/:tableName/columns",
|
|
||||||
authenticateToken,
|
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
|
||||||
try {
|
|
||||||
const connectionId = parseInt(req.params.connectionId);
|
|
||||||
const tableName = req.params.tableName;
|
|
||||||
|
|
||||||
if (isNaN(connectionId)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "유효하지 않은 커넥션 ID입니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!tableName || tableName.trim() === "") {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "테이블명이 입력되지 않았습니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`컬럼 정보 조회 요청: connectionId=${connectionId}, table=${tableName}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const columns = await multiConnectionService.getColumnsFromConnection(
|
|
||||||
connectionId,
|
|
||||||
tableName
|
|
||||||
);
|
|
||||||
|
|
||||||
return res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
data: columns,
|
|
||||||
message: `테이블 ${tableName}의 컬럼 정보를 조회했습니다.`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`컬럼 정보 조회 실패: ${error}`);
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
|
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/multi-connection/connections/:connectionId/query
|
|
||||||
* 특정 커넥션에서 데이터 조회
|
|
||||||
*/
|
|
||||||
router.post(
|
|
||||||
"/connections/:connectionId/query",
|
|
||||||
authenticateToken,
|
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
|
||||||
try {
|
|
||||||
const connectionId = parseInt(req.params.connectionId);
|
|
||||||
const { tableName, conditions } = req.body;
|
|
||||||
|
|
||||||
if (isNaN(connectionId)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "유효하지 않은 커넥션 ID입니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!tableName) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "테이블명이 입력되지 않았습니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`데이터 조회 요청: connectionId=${connectionId}, table=${tableName}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = await multiConnectionService.fetchDataFromConnection(
|
|
||||||
connectionId,
|
|
||||||
tableName,
|
|
||||||
conditions
|
|
||||||
);
|
|
||||||
|
|
||||||
return res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
data: data,
|
|
||||||
message: `데이터 조회가 완료되었습니다. (${data.length}건)`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`데이터 조회 실패: ${error}`);
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "데이터 조회 중 오류가 발생했습니다.",
|
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/multi-connection/connections/:connectionId/insert
|
|
||||||
* 특정 커넥션에 데이터 삽입
|
|
||||||
*/
|
|
||||||
router.post(
|
|
||||||
"/connections/:connectionId/insert",
|
|
||||||
authenticateToken,
|
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
|
||||||
try {
|
|
||||||
const connectionId = parseInt(req.params.connectionId);
|
|
||||||
const { tableName, data } = req.body;
|
|
||||||
|
|
||||||
if (isNaN(connectionId)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "유효하지 않은 커넥션 ID입니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!tableName || !data) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "테이블명과 데이터가 필요합니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`데이터 삽입 요청: connectionId=${connectionId}, table=${tableName}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await multiConnectionService.insertDataToConnection(
|
|
||||||
connectionId,
|
|
||||||
tableName,
|
|
||||||
data
|
|
||||||
);
|
|
||||||
|
|
||||||
return res.status(201).json({
|
|
||||||
success: true,
|
|
||||||
data: result,
|
|
||||||
message: "데이터 삽입이 완료되었습니다.",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`데이터 삽입 실패: ${error}`);
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "데이터 삽입 중 오류가 발생했습니다.",
|
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PUT /api/multi-connection/connections/:connectionId/update
|
|
||||||
* 특정 커넥션의 데이터 업데이트
|
|
||||||
*/
|
|
||||||
router.put(
|
|
||||||
"/connections/:connectionId/update",
|
|
||||||
authenticateToken,
|
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
|
||||||
try {
|
|
||||||
const connectionId = parseInt(req.params.connectionId);
|
|
||||||
const { tableName, data, conditions } = req.body;
|
|
||||||
|
|
||||||
if (isNaN(connectionId)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "유효하지 않은 커넥션 ID입니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!tableName || !data || !conditions) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "테이블명, 데이터, 조건이 모두 필요합니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`데이터 업데이트 요청: connectionId=${connectionId}, table=${tableName}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await multiConnectionService.updateDataToConnection(
|
|
||||||
connectionId,
|
|
||||||
tableName,
|
|
||||||
data,
|
|
||||||
conditions
|
|
||||||
);
|
|
||||||
|
|
||||||
return res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
data: result,
|
|
||||||
message: `데이터 업데이트가 완료되었습니다. (${result.length}건)`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`데이터 업데이트 실패: ${error}`);
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "데이터 업데이트 중 오류가 발생했습니다.",
|
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /api/multi-connection/connections/:connectionId/delete
|
|
||||||
* 특정 커넥션에서 데이터 삭제
|
|
||||||
*/
|
|
||||||
router.delete(
|
|
||||||
"/connections/:connectionId/delete",
|
|
||||||
authenticateToken,
|
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
|
||||||
try {
|
|
||||||
const connectionId = parseInt(req.params.connectionId);
|
|
||||||
const { tableName, conditions, maxDeleteCount } = req.body;
|
|
||||||
|
|
||||||
if (isNaN(connectionId)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "유효하지 않은 커넥션 ID입니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!tableName || !conditions) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "테이블명과 삭제 조건이 필요합니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`데이터 삭제 요청: connectionId=${connectionId}, table=${tableName}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await multiConnectionService.deleteDataFromConnection(
|
|
||||||
connectionId,
|
|
||||||
tableName,
|
|
||||||
conditions,
|
|
||||||
maxDeleteCount || 100
|
|
||||||
);
|
|
||||||
|
|
||||||
return res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
data: result,
|
|
||||||
message: `데이터 삭제가 완료되었습니다. (${result.length}건)`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`데이터 삭제 실패: ${error}`);
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "데이터 삭제 중 오류가 발생했습니다.",
|
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/multi-connection/validate-self-operation
|
|
||||||
* 자기 자신 테이블 작업 검증
|
|
||||||
*/
|
|
||||||
router.post(
|
|
||||||
"/validate-self-operation",
|
|
||||||
authenticateToken,
|
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
|
||||||
try {
|
|
||||||
const { tableName, operation, conditions } = req.body;
|
|
||||||
|
|
||||||
if (!tableName || !operation || !conditions) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "테이블명, 작업 타입, 조건이 모두 필요합니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!["update", "delete"].includes(operation)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "작업 타입은 'update' 또는 'delete'만 허용됩니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`자기 자신 테이블 작업 검증: table=${tableName}, operation=${operation}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const validationResult =
|
|
||||||
await multiConnectionService.validateSelfTableOperation(
|
|
||||||
tableName,
|
|
||||||
operation,
|
|
||||||
conditions
|
|
||||||
);
|
|
||||||
|
|
||||||
return res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
data: validationResult,
|
|
||||||
message: "검증이 완료되었습니다.",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`자기 자신 테이블 작업 검증 실패: ${error}`);
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "검증 중 오류가 발생했습니다.",
|
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
|
|
@ -1,692 +0,0 @@
|
||||||
/**
|
|
||||||
* 확장된 데이터플로우 제어 서비스
|
|
||||||
* 다중 커넥션 지원 및 외부 DB 연동 기능 포함
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
DataflowControlService,
|
|
||||||
ControlAction,
|
|
||||||
ControlCondition,
|
|
||||||
} from "./dataflowControlService";
|
|
||||||
import { MultiConnectionQueryService } from "./multiConnectionQueryService";
|
|
||||||
import { logger } from "../utils/logger";
|
|
||||||
|
|
||||||
export interface EnhancedControlAction extends ControlAction {
|
|
||||||
// 🆕 커넥션 정보 추가
|
|
||||||
fromConnection?: {
|
|
||||||
connectionId?: number;
|
|
||||||
connectionName?: string;
|
|
||||||
dbType?: string;
|
|
||||||
};
|
|
||||||
toConnection?: {
|
|
||||||
connectionId?: number;
|
|
||||||
connectionName?: string;
|
|
||||||
dbType?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 🆕 명시적 테이블 정보
|
|
||||||
fromTable?: string;
|
|
||||||
targetTable: string;
|
|
||||||
|
|
||||||
// 🆕 UPDATE 액션 관련 필드
|
|
||||||
updateConditions?: UpdateCondition[];
|
|
||||||
updateFields?: UpdateFieldMapping[];
|
|
||||||
|
|
||||||
// 🆕 DELETE 액션 관련 필드
|
|
||||||
deleteConditions?: DeleteCondition[];
|
|
||||||
deleteWhereConditions?: DeleteWhereCondition[];
|
|
||||||
maxDeleteCount?: number;
|
|
||||||
requireConfirmation?: boolean;
|
|
||||||
dryRunFirst?: boolean;
|
|
||||||
logAllDeletes?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateCondition {
|
|
||||||
id: string;
|
|
||||||
fromColumn: string;
|
|
||||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN" | "NOT IN";
|
|
||||||
value: string | string[];
|
|
||||||
logicalOperator?: "AND" | "OR";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateFieldMapping {
|
|
||||||
id: string;
|
|
||||||
fromColumn: string;
|
|
||||||
toColumn: string;
|
|
||||||
transformFunction?: string;
|
|
||||||
defaultValue?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WhereCondition {
|
|
||||||
id: string;
|
|
||||||
toColumn: string;
|
|
||||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN" | "NOT IN";
|
|
||||||
valueSource: "from_column" | "static" | "current_timestamp";
|
|
||||||
fromColumn?: string; // valueSource가 "from_column"인 경우
|
|
||||||
staticValue?: string; // valueSource가 "static"인 경우
|
|
||||||
logicalOperator?: "AND" | "OR";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeleteCondition {
|
|
||||||
id: string;
|
|
||||||
fromColumn: string;
|
|
||||||
operator:
|
|
||||||
| "="
|
|
||||||
| "!="
|
|
||||||
| ">"
|
|
||||||
| "<"
|
|
||||||
| ">="
|
|
||||||
| "<="
|
|
||||||
| "LIKE"
|
|
||||||
| "IN"
|
|
||||||
| "NOT IN"
|
|
||||||
| "EXISTS"
|
|
||||||
| "NOT EXISTS";
|
|
||||||
value: string | string[];
|
|
||||||
logicalOperator?: "AND" | "OR";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeleteWhereCondition {
|
|
||||||
id: string;
|
|
||||||
toColumn: string;
|
|
||||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN" | "NOT IN";
|
|
||||||
valueSource: "from_column" | "static" | "condition_result";
|
|
||||||
fromColumn?: string;
|
|
||||||
staticValue?: string;
|
|
||||||
logicalOperator?: "AND" | "OR";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeleteSafetySettings {
|
|
||||||
maxDeleteCount: number;
|
|
||||||
requireConfirmation: boolean;
|
|
||||||
dryRunFirst: boolean;
|
|
||||||
logAllDeletes: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExecutionResult {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
executedActions?: any[];
|
|
||||||
errors?: string[];
|
|
||||||
warnings?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class EnhancedDataflowControlService extends DataflowControlService {
|
|
||||||
private multiConnectionService: MultiConnectionQueryService;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.multiConnectionService = new MultiConnectionQueryService();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 확장된 데이터플로우 제어 실행
|
|
||||||
*/
|
|
||||||
async executeDataflowControl(
|
|
||||||
diagramId: number,
|
|
||||||
relationshipId: string,
|
|
||||||
triggerType: "insert" | "update" | "delete",
|
|
||||||
sourceData: Record<string, any>,
|
|
||||||
tableName: string,
|
|
||||||
// 🆕 추가 매개변수
|
|
||||||
sourceConnectionId?: number,
|
|
||||||
targetConnectionId?: number
|
|
||||||
): Promise<ExecutionResult> {
|
|
||||||
try {
|
|
||||||
logger.info(
|
|
||||||
`확장된 데이터플로우 제어 실행 시작: diagram=${diagramId}, trigger=${triggerType}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// 기본 실행 결과
|
|
||||||
const result: ExecutionResult = {
|
|
||||||
success: true,
|
|
||||||
message: "데이터플로우 제어가 성공적으로 실행되었습니다.",
|
|
||||||
executedActions: [],
|
|
||||||
errors: [],
|
|
||||||
warnings: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// 다이어그램 설정 조회
|
|
||||||
const diagram = await this.getDiagramById(diagramId);
|
|
||||||
if (!diagram) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: "다이어그램을 찾을 수 없습니다.",
|
|
||||||
errors: [`다이어그램 ID ${diagramId}를 찾을 수 없습니다.`],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 제어 계획 파싱
|
|
||||||
const plan = this.parsePlan(diagram.plan);
|
|
||||||
if (!plan.actions || plan.actions.length === 0) {
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: "실행할 액션이 없습니다.",
|
|
||||||
executedActions: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 각 액션 실행
|
|
||||||
for (const action of plan.actions) {
|
|
||||||
try {
|
|
||||||
const enhancedAction = action as EnhancedControlAction;
|
|
||||||
let actionResult: any;
|
|
||||||
|
|
||||||
switch (enhancedAction.actionType) {
|
|
||||||
case "insert":
|
|
||||||
actionResult = await this.executeMultiConnectionInsert(
|
|
||||||
enhancedAction,
|
|
||||||
sourceData,
|
|
||||||
sourceConnectionId,
|
|
||||||
targetConnectionId
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "update":
|
|
||||||
actionResult = await this.executeMultiConnectionUpdate(
|
|
||||||
enhancedAction,
|
|
||||||
sourceData,
|
|
||||||
sourceConnectionId,
|
|
||||||
targetConnectionId
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "delete":
|
|
||||||
actionResult = await this.executeMultiConnectionDelete(
|
|
||||||
enhancedAction,
|
|
||||||
sourceData,
|
|
||||||
sourceConnectionId,
|
|
||||||
targetConnectionId
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw new Error(
|
|
||||||
`지원하지 않는 액션 타입입니다: ${enhancedAction.actionType}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
result.executedActions!.push({
|
|
||||||
actionId: enhancedAction.id,
|
|
||||||
actionType: enhancedAction.actionType,
|
|
||||||
result: actionResult,
|
|
||||||
});
|
|
||||||
} catch (actionError) {
|
|
||||||
const errorMessage = `액션 ${action.id} 실행 실패: ${actionError instanceof Error ? actionError.message : actionError}`;
|
|
||||||
logger.error(errorMessage);
|
|
||||||
result.errors!.push(errorMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 실행 결과 판정
|
|
||||||
if (result.errors!.length > 0) {
|
|
||||||
result.success = false;
|
|
||||||
result.message = `일부 액션 실행에 실패했습니다. 성공: ${result.executedActions!.length}, 실패: ${result.errors!.length}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`확장된 데이터플로우 제어 실행 완료: success=${result.success}`
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`확장된 데이터플로우 제어 실행 실패: ${error}`);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: "데이터플로우 제어 실행 중 오류가 발생했습니다.",
|
|
||||||
errors: [error instanceof Error ? error.message : String(error)],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 🆕 다중 커넥션 INSERT 실행
|
|
||||||
*/
|
|
||||||
private async executeMultiConnectionInsert(
|
|
||||||
action: EnhancedControlAction,
|
|
||||||
sourceData: Record<string, any>,
|
|
||||||
sourceConnectionId?: number,
|
|
||||||
targetConnectionId?: number
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
logger.info(`다중 커넥션 INSERT 실행: action=${action.id}`);
|
|
||||||
|
|
||||||
// 커넥션 ID 결정
|
|
||||||
const fromConnId =
|
|
||||||
sourceConnectionId || action.fromConnection?.connectionId || 0;
|
|
||||||
const toConnId =
|
|
||||||
targetConnectionId || action.toConnection?.connectionId || 0;
|
|
||||||
|
|
||||||
// FROM 테이블에서 소스 데이터 조회 (조건이 있는 경우)
|
|
||||||
let fromData = sourceData;
|
|
||||||
if (
|
|
||||||
action.fromTable &&
|
|
||||||
action.conditions &&
|
|
||||||
action.conditions.length > 0
|
|
||||||
) {
|
|
||||||
const queryConditions = this.buildQueryConditions(
|
|
||||||
action.conditions,
|
|
||||||
sourceData
|
|
||||||
);
|
|
||||||
const fromResults =
|
|
||||||
await this.multiConnectionService.fetchDataFromConnection(
|
|
||||||
fromConnId,
|
|
||||||
action.fromTable,
|
|
||||||
queryConditions
|
|
||||||
);
|
|
||||||
|
|
||||||
if (fromResults.length === 0) {
|
|
||||||
logger.info(`FROM 테이블에서 조건에 맞는 데이터가 없습니다.`);
|
|
||||||
return {
|
|
||||||
inserted: 0,
|
|
||||||
message: "조건에 맞는 소스 데이터가 없습니다.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
fromData = fromResults[0]; // 첫 번째 결과 사용
|
|
||||||
}
|
|
||||||
|
|
||||||
// 필드 매핑 적용
|
|
||||||
const mappedData = this.applyFieldMappings(
|
|
||||||
action.fieldMappings,
|
|
||||||
fromData
|
|
||||||
);
|
|
||||||
|
|
||||||
// TO 테이블에 데이터 삽입
|
|
||||||
const insertResult =
|
|
||||||
await this.multiConnectionService.insertDataToConnection(
|
|
||||||
toConnId,
|
|
||||||
action.targetTable,
|
|
||||||
mappedData
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info(`다중 커넥션 INSERT 완료`);
|
|
||||||
return insertResult;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`다중 커넥션 INSERT 실패: ${error}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 🆕 다중 커넥션 UPDATE 실행
|
|
||||||
*/
|
|
||||||
private async executeMultiConnectionUpdate(
|
|
||||||
action: EnhancedControlAction,
|
|
||||||
sourceData: Record<string, any>,
|
|
||||||
sourceConnectionId?: number,
|
|
||||||
targetConnectionId?: number
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
logger.info(`다중 커넥션 UPDATE 실행: action=${action.id}`);
|
|
||||||
|
|
||||||
// 커넥션 ID 결정
|
|
||||||
const fromConnId =
|
|
||||||
sourceConnectionId || action.fromConnection?.connectionId || 0;
|
|
||||||
const toConnId =
|
|
||||||
targetConnectionId || action.toConnection?.connectionId || 0;
|
|
||||||
|
|
||||||
// UPDATE 조건 확인
|
|
||||||
if (!action.updateConditions || action.updateConditions.length === 0) {
|
|
||||||
throw new Error("UPDATE 작업에는 업데이트 조건이 필요합니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// FROM 테이블에서 업데이트 조건 확인
|
|
||||||
const updateConditions = this.buildUpdateConditions(
|
|
||||||
action.updateConditions,
|
|
||||||
sourceData
|
|
||||||
);
|
|
||||||
const fromResults =
|
|
||||||
await this.multiConnectionService.fetchDataFromConnection(
|
|
||||||
fromConnId,
|
|
||||||
action.fromTable || action.targetTable,
|
|
||||||
updateConditions
|
|
||||||
);
|
|
||||||
|
|
||||||
if (fromResults.length === 0) {
|
|
||||||
logger.info(`업데이트 조건에 맞는 데이터가 없습니다.`);
|
|
||||||
return {
|
|
||||||
updated: 0,
|
|
||||||
message: "업데이트 조건에 맞는 데이터가 없습니다.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 업데이트 필드 매핑 적용
|
|
||||||
const updateData = this.applyUpdateFieldMappings(
|
|
||||||
action.updateFields || [],
|
|
||||||
fromResults[0]
|
|
||||||
);
|
|
||||||
|
|
||||||
// WHERE 조건 구성 (TO 테이블 대상)
|
|
||||||
const whereConditions = this.buildWhereConditions(
|
|
||||||
action.updateFields || [],
|
|
||||||
fromResults[0]
|
|
||||||
);
|
|
||||||
|
|
||||||
// TO 테이블 데이터 업데이트
|
|
||||||
const updateResult =
|
|
||||||
await this.multiConnectionService.updateDataToConnection(
|
|
||||||
toConnId,
|
|
||||||
action.targetTable,
|
|
||||||
updateData,
|
|
||||||
whereConditions
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info(`다중 커넥션 UPDATE 완료`);
|
|
||||||
return updateResult;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`다중 커넥션 UPDATE 실패: ${error}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 🆕 다중 커넥션 DELETE 실행
|
|
||||||
*/
|
|
||||||
private async executeMultiConnectionDelete(
|
|
||||||
action: EnhancedControlAction,
|
|
||||||
sourceData: Record<string, any>,
|
|
||||||
sourceConnectionId?: number,
|
|
||||||
targetConnectionId?: number
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
logger.info(`다중 커넥션 DELETE 실행: action=${action.id}`);
|
|
||||||
|
|
||||||
// 커넥션 ID 결정
|
|
||||||
const fromConnId =
|
|
||||||
sourceConnectionId || action.fromConnection?.connectionId || 0;
|
|
||||||
const toConnId =
|
|
||||||
targetConnectionId || action.toConnection?.connectionId || 0;
|
|
||||||
|
|
||||||
// DELETE 조건 확인
|
|
||||||
if (!action.deleteConditions || action.deleteConditions.length === 0) {
|
|
||||||
throw new Error("DELETE 작업에는 삭제 조건이 필요합니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// FROM 테이블에서 삭제 트리거 조건 확인
|
|
||||||
const deleteConditions = this.buildDeleteConditions(
|
|
||||||
action.deleteConditions,
|
|
||||||
sourceData
|
|
||||||
);
|
|
||||||
const fromResults =
|
|
||||||
await this.multiConnectionService.fetchDataFromConnection(
|
|
||||||
fromConnId,
|
|
||||||
action.fromTable || action.targetTable,
|
|
||||||
deleteConditions
|
|
||||||
);
|
|
||||||
|
|
||||||
if (fromResults.length === 0) {
|
|
||||||
logger.info(`삭제 조건에 맞는 데이터가 없습니다.`);
|
|
||||||
return { deleted: 0, message: "삭제 조건에 맞는 데이터가 없습니다." };
|
|
||||||
}
|
|
||||||
|
|
||||||
// WHERE 조건 구성 (TO 테이블 대상)
|
|
||||||
const whereConditions = this.buildDeleteWhereConditions(
|
|
||||||
action.deleteWhereConditions || [],
|
|
||||||
fromResults[0]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!whereConditions || Object.keys(whereConditions).length === 0) {
|
|
||||||
throw new Error("DELETE 작업에는 WHERE 조건이 필수입니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 안전장치 적용
|
|
||||||
const maxDeleteCount = action.maxDeleteCount || 100;
|
|
||||||
|
|
||||||
// Dry Run 실행 (선택사항)
|
|
||||||
if (action.dryRunFirst) {
|
|
||||||
const countResult =
|
|
||||||
await this.multiConnectionService.fetchDataFromConnection(
|
|
||||||
toConnId,
|
|
||||||
action.targetTable,
|
|
||||||
whereConditions
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info(`삭제 예상 개수: ${countResult.length}건`);
|
|
||||||
|
|
||||||
if (countResult.length > maxDeleteCount) {
|
|
||||||
throw new Error(
|
|
||||||
`삭제 대상이 ${countResult.length}건으로 최대 허용 개수(${maxDeleteCount})를 초과합니다.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TO 테이블에서 데이터 삭제
|
|
||||||
const deleteResult =
|
|
||||||
await this.multiConnectionService.deleteDataFromConnection(
|
|
||||||
toConnId,
|
|
||||||
action.targetTable,
|
|
||||||
whereConditions,
|
|
||||||
maxDeleteCount
|
|
||||||
);
|
|
||||||
|
|
||||||
// 삭제 로그 기록 (선택사항)
|
|
||||||
if (action.logAllDeletes) {
|
|
||||||
logger.info(
|
|
||||||
`삭제 실행 로그: ${JSON.stringify({
|
|
||||||
action: action.id,
|
|
||||||
deletedCount: deleteResult.length,
|
|
||||||
conditions: whereConditions,
|
|
||||||
})}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`다중 커넥션 DELETE 완료`);
|
|
||||||
return deleteResult;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`다중 커넥션 DELETE 실패: ${error}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 쿼리 조건 구성
|
|
||||||
*/
|
|
||||||
private buildQueryConditions(
|
|
||||||
conditions: ControlCondition[],
|
|
||||||
sourceData: Record<string, any>
|
|
||||||
): Record<string, any> {
|
|
||||||
const queryConditions: Record<string, any> = {};
|
|
||||||
|
|
||||||
conditions.forEach((condition) => {
|
|
||||||
if (condition.type === "condition" && condition.field) {
|
|
||||||
let value = condition.value;
|
|
||||||
|
|
||||||
// 소스 데이터에서 값 참조
|
|
||||||
if (
|
|
||||||
typeof value === "string" &&
|
|
||||||
value.startsWith("${") &&
|
|
||||||
value.endsWith("}")
|
|
||||||
) {
|
|
||||||
const fieldName = value.slice(2, -1);
|
|
||||||
value = sourceData[fieldName];
|
|
||||||
}
|
|
||||||
|
|
||||||
queryConditions[condition.field] = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return queryConditions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UPDATE 조건 구성
|
|
||||||
*/
|
|
||||||
private buildUpdateConditions(
|
|
||||||
updateConditions: UpdateCondition[],
|
|
||||||
sourceData: Record<string, any>
|
|
||||||
): Record<string, any> {
|
|
||||||
const conditions: Record<string, any> = {};
|
|
||||||
|
|
||||||
updateConditions.forEach((condition) => {
|
|
||||||
let value = condition.value;
|
|
||||||
|
|
||||||
// 소스 데이터에서 값 참조
|
|
||||||
if (
|
|
||||||
typeof value === "string" &&
|
|
||||||
value.startsWith("${") &&
|
|
||||||
value.endsWith("}")
|
|
||||||
) {
|
|
||||||
const fieldName = value.slice(2, -1);
|
|
||||||
value = sourceData[fieldName];
|
|
||||||
}
|
|
||||||
|
|
||||||
conditions[condition.fromColumn] = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
return conditions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UPDATE 필드 매핑 적용
|
|
||||||
*/
|
|
||||||
private applyUpdateFieldMappings(
|
|
||||||
updateFields: UpdateFieldMapping[],
|
|
||||||
fromData: Record<string, any>
|
|
||||||
): Record<string, any> {
|
|
||||||
const updateData: Record<string, any> = {};
|
|
||||||
|
|
||||||
updateFields.forEach((mapping) => {
|
|
||||||
let value = fromData[mapping.fromColumn];
|
|
||||||
|
|
||||||
// 기본값 사용
|
|
||||||
if (value === undefined || value === null) {
|
|
||||||
value = mapping.defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 변환 함수 적용 (추후 구현 가능)
|
|
||||||
if (mapping.transformFunction) {
|
|
||||||
// TODO: 변환 함수 로직 구현
|
|
||||||
}
|
|
||||||
|
|
||||||
updateData[mapping.toColumn] = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
return updateData;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WHERE 조건 구성 (UPDATE용)
|
|
||||||
*/
|
|
||||||
private buildWhereConditions(
|
|
||||||
updateFields: UpdateFieldMapping[],
|
|
||||||
fromData: Record<string, any>
|
|
||||||
): Record<string, any> {
|
|
||||||
const whereConditions: Record<string, any> = {};
|
|
||||||
|
|
||||||
// 기본적으로 ID 필드로 WHERE 조건 구성
|
|
||||||
if (fromData.id) {
|
|
||||||
whereConditions.id = fromData.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
return whereConditions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE 조건 구성
|
|
||||||
*/
|
|
||||||
private buildDeleteConditions(
|
|
||||||
deleteConditions: DeleteCondition[],
|
|
||||||
sourceData: Record<string, any>
|
|
||||||
): Record<string, any> {
|
|
||||||
const conditions: Record<string, any> = {};
|
|
||||||
|
|
||||||
deleteConditions.forEach((condition) => {
|
|
||||||
let value = condition.value;
|
|
||||||
|
|
||||||
// 소스 데이터에서 값 참조
|
|
||||||
if (
|
|
||||||
typeof value === "string" &&
|
|
||||||
value.startsWith("${") &&
|
|
||||||
value.endsWith("}")
|
|
||||||
) {
|
|
||||||
const fieldName = value.slice(2, -1);
|
|
||||||
value = sourceData[fieldName];
|
|
||||||
}
|
|
||||||
|
|
||||||
conditions[condition.fromColumn] = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
return conditions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE WHERE 조건 구성
|
|
||||||
*/
|
|
||||||
private buildDeleteWhereConditions(
|
|
||||||
whereConditions: DeleteWhereCondition[],
|
|
||||||
fromData: Record<string, any>
|
|
||||||
): Record<string, any> {
|
|
||||||
const conditions: Record<string, any> = {};
|
|
||||||
|
|
||||||
whereConditions.forEach((condition) => {
|
|
||||||
let value: any;
|
|
||||||
|
|
||||||
switch (condition.valueSource) {
|
|
||||||
case "from_column":
|
|
||||||
if (condition.fromColumn) {
|
|
||||||
value = fromData[condition.fromColumn];
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "static":
|
|
||||||
value = condition.staticValue;
|
|
||||||
break;
|
|
||||||
case "condition_result":
|
|
||||||
// 조건 결과를 사용 (추후 구현)
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value !== undefined && value !== null) {
|
|
||||||
conditions[condition.toColumn] = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return conditions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 필드 매핑 적용
|
|
||||||
*/
|
|
||||||
private applyFieldMappings(
|
|
||||||
fieldMappings: any[],
|
|
||||||
sourceData: Record<string, any>
|
|
||||||
): Record<string, any> {
|
|
||||||
const mappedData: Record<string, any> = {};
|
|
||||||
|
|
||||||
fieldMappings.forEach((mapping) => {
|
|
||||||
let value: any;
|
|
||||||
|
|
||||||
if (mapping.sourceField) {
|
|
||||||
value = sourceData[mapping.sourceField];
|
|
||||||
} else if (mapping.defaultValue !== undefined) {
|
|
||||||
value = mapping.defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value !== undefined) {
|
|
||||||
mappedData[mapping.targetField] = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return mappedData;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 다이어그램 조회 (부모 클래스에서 가져오기)
|
|
||||||
*/
|
|
||||||
private async getDiagramById(diagramId: number): Promise<any> {
|
|
||||||
// 부모 클래스의 메서드 호출 또는 직접 구현
|
|
||||||
// 임시로 간단한 구현
|
|
||||||
return { id: diagramId, plan: "{}" };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 계획 파싱 (부모 클래스에서 가져오기)
|
|
||||||
*/
|
|
||||||
private parsePlan(planJson: string): any {
|
|
||||||
try {
|
|
||||||
return JSON.parse(planJson);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`계획 파싱 실패: ${error}`);
|
|
||||||
return { actions: [] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -90,23 +90,26 @@ export class ExternalDbConnectionService {
|
||||||
try {
|
try {
|
||||||
// 기본 연결 목록 조회
|
// 기본 연결 목록 조회
|
||||||
const connectionsResult = await this.getConnections(filter);
|
const connectionsResult = await this.getConnections(filter);
|
||||||
|
|
||||||
if (!connectionsResult.success || !connectionsResult.data) {
|
if (!connectionsResult.success || !connectionsResult.data) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "연결 목록 조회에 실패했습니다.",
|
message: "연결 목록 조회에 실패했습니다."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// DB 타입 카테고리 정보 조회
|
// DB 타입 카테고리 정보 조회
|
||||||
const categories = await prisma.db_type_categories.findMany({
|
const categories = await prisma.db_type_categories.findMany({
|
||||||
where: { is_active: true },
|
where: { is_active: true },
|
||||||
orderBy: [{ sort_order: "asc" }, { display_name: "asc" }],
|
orderBy: [
|
||||||
|
{ sort_order: 'asc' },
|
||||||
|
{ display_name: 'asc' }
|
||||||
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
// DB 타입별로 그룹화
|
// DB 타입별로 그룹화
|
||||||
const groupedConnections: Record<string, any> = {};
|
const groupedConnections: Record<string, any> = {};
|
||||||
|
|
||||||
// 카테고리 정보를 포함한 그룹 초기화
|
// 카테고리 정보를 포함한 그룹 초기화
|
||||||
categories.forEach((category: any) => {
|
categories.forEach((category: any) => {
|
||||||
groupedConnections[category.type_code] = {
|
groupedConnections[category.type_code] = {
|
||||||
|
|
@ -115,36 +118,36 @@ export class ExternalDbConnectionService {
|
||||||
display_name: category.display_name,
|
display_name: category.display_name,
|
||||||
icon: category.icon,
|
icon: category.icon,
|
||||||
color: category.color,
|
color: category.color,
|
||||||
sort_order: category.sort_order,
|
sort_order: category.sort_order
|
||||||
},
|
},
|
||||||
connections: [],
|
connections: []
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// 연결을 해당 타입 그룹에 배치
|
// 연결을 해당 타입 그룹에 배치
|
||||||
connectionsResult.data.forEach((connection) => {
|
connectionsResult.data.forEach(connection => {
|
||||||
if (groupedConnections[connection.db_type]) {
|
if (groupedConnections[connection.db_type]) {
|
||||||
groupedConnections[connection.db_type].connections.push(connection);
|
groupedConnections[connection.db_type].connections.push(connection);
|
||||||
} else {
|
} else {
|
||||||
// 카테고리에 없는 DB 타입인 경우 기타 그룹에 추가
|
// 카테고리에 없는 DB 타입인 경우 기타 그룹에 추가
|
||||||
if (!groupedConnections["other"]) {
|
if (!groupedConnections['other']) {
|
||||||
groupedConnections["other"] = {
|
groupedConnections['other'] = {
|
||||||
category: {
|
category: {
|
||||||
type_code: "other",
|
type_code: 'other',
|
||||||
display_name: "기타",
|
display_name: '기타',
|
||||||
icon: "database",
|
icon: 'database',
|
||||||
color: "#6B7280",
|
color: '#6B7280',
|
||||||
sort_order: 999,
|
sort_order: 999
|
||||||
},
|
},
|
||||||
connections: [],
|
connections: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
groupedConnections["other"].connections.push(connection);
|
groupedConnections['other'].connections.push(connection);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 연결이 없는 빈 그룹 제거
|
// 연결이 없는 빈 그룹 제거
|
||||||
Object.keys(groupedConnections).forEach((key) => {
|
Object.keys(groupedConnections).forEach(key => {
|
||||||
if (groupedConnections[key].connections.length === 0) {
|
if (groupedConnections[key].connections.length === 0) {
|
||||||
delete groupedConnections[key];
|
delete groupedConnections[key];
|
||||||
}
|
}
|
||||||
|
|
@ -153,14 +156,14 @@ export class ExternalDbConnectionService {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: groupedConnections,
|
data: groupedConnections,
|
||||||
message: `DB 타입별로 그룹화된 연결 목록을 조회했습니다.`,
|
message: `DB 타입별로 그룹화된 연결 목록을 조회했습니다.`
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("그룹화된 연결 목록 조회 실패:", error);
|
console.error("그룹화된 연결 목록 조회 실패:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "그룹화된 연결 목록 조회 중 오류가 발생했습니다.",
|
message: "그룹화된 연결 목록 조회 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -332,34 +335,20 @@ export class ExternalDbConnectionService {
|
||||||
database: data.database_name || existingConnection.database_name,
|
database: data.database_name || existingConnection.database_name,
|
||||||
user: data.username || existingConnection.username,
|
user: data.username || existingConnection.username,
|
||||||
password: data.password, // 새로 입력된 비밀번호로 테스트
|
password: data.password, // 새로 입력된 비밀번호로 테스트
|
||||||
connectionTimeoutMillis:
|
connectionTimeoutMillis: data.connection_timeout != null ? data.connection_timeout * 1000 : undefined,
|
||||||
data.connection_timeout != null
|
queryTimeoutMillis: data.query_timeout != null ? data.query_timeout * 1000 : undefined,
|
||||||
? data.connection_timeout * 1000
|
ssl: (data.ssl_enabled || existingConnection.ssl_enabled) === "Y" ? { rejectUnauthorized: false } : false
|
||||||
: undefined,
|
|
||||||
queryTimeoutMillis:
|
|
||||||
data.query_timeout != null ? data.query_timeout * 1000 : undefined,
|
|
||||||
ssl:
|
|
||||||
(data.ssl_enabled || existingConnection.ssl_enabled) === "Y"
|
|
||||||
? { rejectUnauthorized: false }
|
|
||||||
: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 연결 테스트 수행
|
// 연결 테스트 수행
|
||||||
const connector = await DatabaseConnectorFactory.createConnector(
|
const connector = await DatabaseConnectorFactory.createConnector(existingConnection.db_type, testConfig, id);
|
||||||
existingConnection.db_type,
|
|
||||||
testConfig,
|
|
||||||
id
|
|
||||||
);
|
|
||||||
const testResult = await connector.testConnection();
|
const testResult = await connector.testConnection();
|
||||||
|
|
||||||
if (!testResult.success) {
|
if (!testResult.success) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message:
|
message: "새로운 연결 정보로 테스트에 실패했습니다. 수정할 수 없습니다.",
|
||||||
"새로운 연결 정보로 테스트에 실패했습니다. 수정할 수 없습니다.",
|
error: testResult.error ? `${testResult.error.code}: ${testResult.error.details}` : undefined
|
||||||
error: testResult.error
|
|
||||||
? `${testResult.error.code}: ${testResult.error.details}`
|
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -451,7 +440,7 @@ export class ExternalDbConnectionService {
|
||||||
try {
|
try {
|
||||||
// 저장된 연결 정보 조회
|
// 저장된 연결 정보 조회
|
||||||
const connection = await prisma.external_db_connections.findUnique({
|
const connection = await prisma.external_db_connections.findUnique({
|
||||||
where: { id },
|
where: { id }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!connection) {
|
if (!connection) {
|
||||||
|
|
@ -460,8 +449,8 @@ export class ExternalDbConnectionService {
|
||||||
message: "연결 정보를 찾을 수 없습니다.",
|
message: "연결 정보를 찾을 수 없습니다.",
|
||||||
error: {
|
error: {
|
||||||
code: "CONNECTION_NOT_FOUND",
|
code: "CONNECTION_NOT_FOUND",
|
||||||
details: `ID ${id}에 해당하는 연결 정보가 없습니다.`,
|
details: `ID ${id}에 해당하는 연결 정보가 없습니다.`
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -469,14 +458,10 @@ export class ExternalDbConnectionService {
|
||||||
let password: string | null;
|
let password: string | null;
|
||||||
if (testData?.password) {
|
if (testData?.password) {
|
||||||
password = testData.password;
|
password = testData.password;
|
||||||
console.log(
|
console.log(`🔍 [연결테스트] 새로 입력된 비밀번호 사용: ${password.substring(0, 3)}***`);
|
||||||
`🔍 [연결테스트] 새로 입력된 비밀번호 사용: ${password.substring(0, 3)}***`
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
password = await this.getDecryptedPassword(id);
|
password = await this.getDecryptedPassword(id);
|
||||||
console.log(
|
console.log(`🔍 [연결테스트] 저장된 비밀번호 사용: ${password ? password.substring(0, 3) + '***' : 'null'}`);
|
||||||
`🔍 [연결테스트] 저장된 비밀번호 사용: ${password ? password.substring(0, 3) + "***" : "null"}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!password) {
|
if (!password) {
|
||||||
|
|
@ -485,8 +470,8 @@ export class ExternalDbConnectionService {
|
||||||
message: "비밀번호 복호화에 실패했습니다.",
|
message: "비밀번호 복호화에 실패했습니다.",
|
||||||
error: {
|
error: {
|
||||||
code: "DECRYPTION_FAILED",
|
code: "DECRYPTION_FAILED",
|
||||||
details: "저장된 비밀번호를 복호화할 수 없습니다.",
|
details: "저장된 비밀번호를 복호화할 수 없습니다."
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -497,65 +482,44 @@ export class ExternalDbConnectionService {
|
||||||
database: connection.database_name,
|
database: connection.database_name,
|
||||||
user: connection.username,
|
user: connection.username,
|
||||||
password: password,
|
password: password,
|
||||||
connectionTimeoutMillis:
|
connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined,
|
||||||
connection.connection_timeout != null
|
queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined,
|
||||||
? connection.connection_timeout * 1000
|
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
||||||
: undefined,
|
|
||||||
queryTimeoutMillis:
|
|
||||||
connection.query_timeout != null
|
|
||||||
? connection.query_timeout * 1000
|
|
||||||
: undefined,
|
|
||||||
ssl:
|
|
||||||
connection.ssl_enabled === "Y"
|
|
||||||
? { rejectUnauthorized: false }
|
|
||||||
: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 연결 테스트용 임시 커넥터 생성 (캐시 사용하지 않음)
|
// 연결 테스트용 임시 커넥터 생성 (캐시 사용하지 않음)
|
||||||
let connector: any;
|
let connector: any;
|
||||||
switch (connection.db_type.toLowerCase()) {
|
switch (connection.db_type.toLowerCase()) {
|
||||||
case "postgresql":
|
case 'postgresql':
|
||||||
const { PostgreSQLConnector } = await import(
|
const { PostgreSQLConnector } = await import('../database/PostgreSQLConnector');
|
||||||
"../database/PostgreSQLConnector"
|
|
||||||
);
|
|
||||||
connector = new PostgreSQLConnector(config);
|
connector = new PostgreSQLConnector(config);
|
||||||
break;
|
break;
|
||||||
case "oracle":
|
case 'oracle':
|
||||||
const { OracleConnector } = await import(
|
const { OracleConnector } = await import('../database/OracleConnector');
|
||||||
"../database/OracleConnector"
|
|
||||||
);
|
|
||||||
connector = new OracleConnector(config);
|
connector = new OracleConnector(config);
|
||||||
break;
|
break;
|
||||||
case "mariadb":
|
case 'mariadb':
|
||||||
case "mysql":
|
case 'mysql':
|
||||||
const { MariaDBConnector } = await import(
|
const { MariaDBConnector } = await import('../database/MariaDBConnector');
|
||||||
"../database/MariaDBConnector"
|
|
||||||
);
|
|
||||||
connector = new MariaDBConnector(config);
|
connector = new MariaDBConnector(config);
|
||||||
break;
|
break;
|
||||||
case "mssql":
|
case 'mssql':
|
||||||
const { MSSQLConnector } = await import("../database/MSSQLConnector");
|
const { MSSQLConnector } = await import('../database/MSSQLConnector');
|
||||||
connector = new MSSQLConnector(config);
|
connector = new MSSQLConnector(config);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(
|
throw new Error(`지원하지 않는 데이터베이스 타입: ${connection.db_type}`);
|
||||||
`지원하지 않는 데이터베이스 타입: ${connection.db_type}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(`🔍 [연결테스트] 새 커넥터로 DB 연결 시도 - Host: ${config.host}, DB: ${config.database}, User: ${config.user}`);
|
||||||
`🔍 [연결테스트] 새 커넥터로 DB 연결 시도 - Host: ${config.host}, DB: ${config.database}, User: ${config.user}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const testResult = await connector.testConnection();
|
const testResult = await connector.testConnection();
|
||||||
console.log(
|
console.log(`🔍 [연결테스트] 결과 - Success: ${testResult.success}, Message: ${testResult.message}`);
|
||||||
`🔍 [연결테스트] 결과 - Success: ${testResult.success}, Message: ${testResult.message}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: testResult.success,
|
success: testResult.success,
|
||||||
message: testResult.message,
|
message: testResult.message,
|
||||||
details: testResult.details,
|
details: testResult.details
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -607,14 +571,7 @@ export class ExternalDbConnectionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// DB 타입 유효성 검사
|
// DB 타입 유효성 검사
|
||||||
const validDbTypes = [
|
const validDbTypes = ["mysql", "postgresql", "oracle", "mssql", "sqlite", "mariadb"];
|
||||||
"mysql",
|
|
||||||
"postgresql",
|
|
||||||
"oracle",
|
|
||||||
"mssql",
|
|
||||||
"sqlite",
|
|
||||||
"mariadb",
|
|
||||||
];
|
|
||||||
if (!validDbTypes.includes(data.db_type)) {
|
if (!validDbTypes.includes(data.db_type)) {
|
||||||
throw new Error("지원하지 않는 DB 타입입니다.");
|
throw new Error("지원하지 않는 DB 타입입니다.");
|
||||||
}
|
}
|
||||||
|
|
@ -652,7 +609,7 @@ export class ExternalDbConnectionService {
|
||||||
// 연결 정보 조회
|
// 연결 정보 조회
|
||||||
console.log("연결 정보 조회 시작:", { id });
|
console.log("연결 정보 조회 시작:", { id });
|
||||||
const connection = await prisma.external_db_connections.findUnique({
|
const connection = await prisma.external_db_connections.findUnique({
|
||||||
where: { id },
|
where: { id }
|
||||||
});
|
});
|
||||||
console.log("조회된 연결 정보:", connection);
|
console.log("조회된 연결 정보:", connection);
|
||||||
|
|
||||||
|
|
@ -660,7 +617,7 @@ export class ExternalDbConnectionService {
|
||||||
console.log("연결 정보를 찾을 수 없음:", { id });
|
console.log("연결 정보를 찾을 수 없음:", { id });
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "연결 정보를 찾을 수 없습니다.",
|
message: "연결 정보를 찾을 수 없습니다."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -669,7 +626,7 @@ export class ExternalDbConnectionService {
|
||||||
if (!decryptedPassword) {
|
if (!decryptedPassword) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "비밀번호 복호화에 실패했습니다.",
|
message: "비밀번호 복호화에 실패했습니다."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -680,39 +637,26 @@ export class ExternalDbConnectionService {
|
||||||
database: connection.database_name,
|
database: connection.database_name,
|
||||||
user: connection.username,
|
user: connection.username,
|
||||||
password: decryptedPassword,
|
password: decryptedPassword,
|
||||||
connectionTimeoutMillis:
|
connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined,
|
||||||
connection.connection_timeout != null
|
queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined,
|
||||||
? connection.connection_timeout * 1000
|
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
||||||
: undefined,
|
|
||||||
queryTimeoutMillis:
|
|
||||||
connection.query_timeout != null
|
|
||||||
? connection.query_timeout * 1000
|
|
||||||
: undefined,
|
|
||||||
ssl:
|
|
||||||
connection.ssl_enabled === "Y"
|
|
||||||
? { rejectUnauthorized: false }
|
|
||||||
: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// DatabaseConnectorFactory를 통한 쿼리 실행
|
// DatabaseConnectorFactory를 통한 쿼리 실행
|
||||||
const connector = await DatabaseConnectorFactory.createConnector(
|
const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, id);
|
||||||
connection.db_type,
|
|
||||||
config,
|
|
||||||
id
|
|
||||||
);
|
|
||||||
const result = await connector.executeQuery(query);
|
const result = await connector.executeQuery(query);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "쿼리가 성공적으로 실행되었습니다.",
|
message: "쿼리가 성공적으로 실행되었습니다.",
|
||||||
data: result.rows,
|
data: result.rows
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("쿼리 실행 오류:", error);
|
console.error("쿼리 실행 오류:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "쿼리 실행 중 오류가 발생했습니다.",
|
message: "쿼리 실행 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -733,8 +677,7 @@ export class ExternalDbConnectionService {
|
||||||
user: connection.username,
|
user: connection.username,
|
||||||
password: password,
|
password: password,
|
||||||
connectionTimeoutMillis: (connection.connection_timeout || 30) * 1000,
|
connectionTimeoutMillis: (connection.connection_timeout || 30) * 1000,
|
||||||
ssl:
|
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false,
|
||||||
connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -743,7 +686,7 @@ export class ExternalDbConnectionService {
|
||||||
host: connection.host,
|
host: connection.host,
|
||||||
port: connection.port,
|
port: connection.port,
|
||||||
database: connection.database_name,
|
database: connection.database_name,
|
||||||
user: connection.username,
|
user: connection.username
|
||||||
});
|
});
|
||||||
console.log("쿼리 실행:", query);
|
console.log("쿼리 실행:", query);
|
||||||
const result = await client.query(query);
|
const result = await client.query(query);
|
||||||
|
|
@ -753,7 +696,7 @@ export class ExternalDbConnectionService {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "쿼리가 성공적으로 실행되었습니다.",
|
message: "쿼리가 성공적으로 실행되었습니다.",
|
||||||
data: result.rows,
|
data: result.rows
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -765,7 +708,7 @@ export class ExternalDbConnectionService {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "쿼리 실행 중 오류가 발생했습니다.",
|
message: "쿼리 실행 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -777,13 +720,13 @@ export class ExternalDbConnectionService {
|
||||||
try {
|
try {
|
||||||
// 연결 정보 조회
|
// 연결 정보 조회
|
||||||
const connection = await prisma.external_db_connections.findUnique({
|
const connection = await prisma.external_db_connections.findUnique({
|
||||||
where: { id },
|
where: { id }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!connection) {
|
if (!connection) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "연결 정보를 찾을 수 없습니다.",
|
message: "연결 정보를 찾을 수 없습니다."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -792,7 +735,7 @@ export class ExternalDbConnectionService {
|
||||||
if (!decryptedPassword) {
|
if (!decryptedPassword) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "비밀번호 복호화에 실패했습니다.",
|
message: "비밀번호 복호화에 실패했습니다."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -803,39 +746,26 @@ export class ExternalDbConnectionService {
|
||||||
database: connection.database_name,
|
database: connection.database_name,
|
||||||
user: connection.username,
|
user: connection.username,
|
||||||
password: decryptedPassword,
|
password: decryptedPassword,
|
||||||
connectionTimeoutMillis:
|
connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined,
|
||||||
connection.connection_timeout != null
|
queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined,
|
||||||
? connection.connection_timeout * 1000
|
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
||||||
: undefined,
|
|
||||||
queryTimeoutMillis:
|
|
||||||
connection.query_timeout != null
|
|
||||||
? connection.query_timeout * 1000
|
|
||||||
: undefined,
|
|
||||||
ssl:
|
|
||||||
connection.ssl_enabled === "Y"
|
|
||||||
? { rejectUnauthorized: false }
|
|
||||||
: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// DatabaseConnectorFactory를 통한 테이블 목록 조회
|
// DatabaseConnectorFactory를 통한 테이블 목록 조회
|
||||||
const connector = await DatabaseConnectorFactory.createConnector(
|
const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, id);
|
||||||
connection.db_type,
|
|
||||||
config,
|
|
||||||
id
|
|
||||||
);
|
|
||||||
const tables = await connector.getTables();
|
const tables = await connector.getTables();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "테이블 목록을 조회했습니다.",
|
message: "테이블 목록을 조회했습니다.",
|
||||||
data: tables,
|
data: tables
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("테이블 목록 조회 오류:", error);
|
console.error("테이블 목록 조회 오류:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -855,8 +785,7 @@ export class ExternalDbConnectionService {
|
||||||
user: connection.username,
|
user: connection.username,
|
||||||
password: password,
|
password: password,
|
||||||
connectionTimeoutMillis: (connection.connection_timeout || 30) * 1000,
|
connectionTimeoutMillis: (connection.connection_timeout || 30) * 1000,
|
||||||
ssl:
|
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
||||||
connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -887,19 +816,19 @@ export class ExternalDbConnectionService {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: result.rows.map((row) => ({
|
data: result.rows.map(row => ({
|
||||||
table_name: row.table_name,
|
table_name: row.table_name,
|
||||||
columns: row.columns || [],
|
columns: row.columns || [],
|
||||||
description: row.table_description,
|
description: row.table_description
|
||||||
})) as TableInfo[],
|
})) as TableInfo[],
|
||||||
message: "테이블 목록을 조회했습니다.",
|
message: "테이블 목록을 조회했습니다."
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await client.end();
|
await client.end();
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -907,42 +836,23 @@ export class ExternalDbConnectionService {
|
||||||
/**
|
/**
|
||||||
* 특정 테이블의 컬럼 정보 조회
|
* 특정 테이블의 컬럼 정보 조회
|
||||||
*/
|
*/
|
||||||
static async getTableColumns(
|
static async getTableColumns(connectionId: number, tableName: string): Promise<ApiResponse<any[]>> {
|
||||||
connectionId: number,
|
|
||||||
tableName: string
|
|
||||||
): Promise<ApiResponse<any[]>> {
|
|
||||||
let client: any = null;
|
let client: any = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const connection = await this.getConnectionById(connectionId);
|
const connection = await this.getConnectionById(connectionId);
|
||||||
if (!connection.success || !connection.data) {
|
if (!connection.success || !connection.data) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "연결 정보를 찾을 수 없습니다.",
|
message: "연결 정보를 찾을 수 없습니다."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectionData = connection.data;
|
const connectionData = connection.data;
|
||||||
|
|
||||||
// 비밀번호 복호화 (실패 시 일반적인 패스워드들 시도)
|
// 비밀번호 복호화
|
||||||
let decryptedPassword: string;
|
const decryptedPassword = PasswordEncryption.decrypt(connectionData.password);
|
||||||
try {
|
|
||||||
decryptedPassword = PasswordEncryption.decrypt(connectionData.password);
|
|
||||||
console.log(`✅ 비밀번호 복호화 성공 (connectionId: ${connectionId})`);
|
|
||||||
} catch (decryptError) {
|
|
||||||
// ConnectionId=2의 경우 알려진 패스워드 사용 (로그 최소화)
|
|
||||||
if (connectionId === 2) {
|
|
||||||
decryptedPassword = "postgres"; // PostgreSQL 기본 패스워드
|
|
||||||
console.log(`💡 ConnectionId=2: 기본 패스워드 사용`);
|
|
||||||
} else {
|
|
||||||
// 다른 연결들은 원본 패스워드 사용
|
|
||||||
console.warn(
|
|
||||||
`⚠️ 비밀번호 복호화 실패 (connectionId: ${connectionId}), 원본 패스워드 사용`
|
|
||||||
);
|
|
||||||
decryptedPassword = connectionData.password;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 연결 설정 준비
|
// 연결 설정 준비
|
||||||
const config = {
|
const config = {
|
||||||
host: connectionData.host,
|
host: connectionData.host,
|
||||||
|
|
@ -950,42 +860,30 @@ export class ExternalDbConnectionService {
|
||||||
database: connectionData.database_name,
|
database: connectionData.database_name,
|
||||||
user: connectionData.username,
|
user: connectionData.username,
|
||||||
password: decryptedPassword,
|
password: decryptedPassword,
|
||||||
connectionTimeoutMillis:
|
connectionTimeoutMillis: connectionData.connection_timeout != null ? connectionData.connection_timeout * 1000 : undefined,
|
||||||
connectionData.connection_timeout != null
|
queryTimeoutMillis: connectionData.query_timeout != null ? connectionData.query_timeout * 1000 : undefined,
|
||||||
? connectionData.connection_timeout * 1000
|
ssl: connectionData.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
||||||
: undefined,
|
|
||||||
queryTimeoutMillis:
|
|
||||||
connectionData.query_timeout != null
|
|
||||||
? connectionData.query_timeout * 1000
|
|
||||||
: undefined,
|
|
||||||
ssl:
|
|
||||||
connectionData.ssl_enabled === "Y"
|
|
||||||
? { rejectUnauthorized: false }
|
|
||||||
: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 데이터베이스 타입에 따른 커넥터 생성
|
// 데이터베이스 타입에 따른 커넥터 생성
|
||||||
const connector = await DatabaseConnectorFactory.createConnector(
|
const connector = await DatabaseConnectorFactory.createConnector(connectionData.db_type, config, connectionId);
|
||||||
connectionData.db_type,
|
|
||||||
config,
|
|
||||||
connectionId
|
|
||||||
);
|
|
||||||
|
|
||||||
// 컬럼 정보 조회
|
// 컬럼 정보 조회
|
||||||
const columns = await connector.getColumns(tableName);
|
const columns = await connector.getColumns(tableName);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: columns,
|
data: columns,
|
||||||
message: "컬럼 정보를 조회했습니다.",
|
message: "컬럼 정보를 조회했습니다."
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("컬럼 정보 조회 오류:", error);
|
console.error("컬럼 정보 조회 오류:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
|
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,802 +0,0 @@
|
||||||
/**
|
|
||||||
* 다중 커넥션 쿼리 실행 서비스
|
|
||||||
* 외부 데이터베이스 커넥션을 통한 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<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 쿼리 구성
|
|
||||||
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<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 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}개`
|
|
||||||
);
|
|
||||||
|
|
||||||
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<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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -12,8 +12,6 @@ export interface ColumnTypeInfo {
|
||||||
columnName: string;
|
columnName: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
dataType: string; // DB 데이터 타입 (varchar, integer 등)
|
dataType: string; // DB 데이터 타입 (varchar, integer 등)
|
||||||
dbType?: string; // DB 타입 (추가됨)
|
|
||||||
webType?: string; // 웹 타입 (추가됨)
|
|
||||||
inputType: string; // 입력 타입 (text, number, date, code, entity, select, checkbox, radio)
|
inputType: string; // 입력 타입 (text, number, date, code, entity, select, checkbox, radio)
|
||||||
detailSettings: string;
|
detailSettings: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@ export class PasswordEncryption {
|
||||||
// 암호화 키 생성 (SECRET_KEY를 해시하여 32바이트 키 생성)
|
// 암호화 키 생성 (SECRET_KEY를 해시하여 32바이트 키 생성)
|
||||||
const key = crypto.scryptSync(this.SECRET_KEY, "salt", 32);
|
const key = crypto.scryptSync(this.SECRET_KEY, "salt", 32);
|
||||||
|
|
||||||
// 암호화 객체 생성 (IV를 명시적으로 사용)
|
// 암호화 객체 생성
|
||||||
const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv);
|
const cipher = crypto.createCipher("aes-256-cbc", key);
|
||||||
|
|
||||||
// 암호화 실행
|
// 암호화 실행
|
||||||
let encrypted = cipher.update(password, "utf8", "hex");
|
let encrypted = cipher.update(password, "utf8", "hex");
|
||||||
|
|
@ -57,37 +57,14 @@ export class PasswordEncryption {
|
||||||
// 암호화 키 생성 (암호화 시와 동일)
|
// 암호화 키 생성 (암호화 시와 동일)
|
||||||
const key = crypto.scryptSync(this.SECRET_KEY, "salt", 32);
|
const key = crypto.scryptSync(this.SECRET_KEY, "salt", 32);
|
||||||
|
|
||||||
try {
|
// 복호화 객체 생성
|
||||||
// 새로운 방식: createDecipheriv 사용 (IV 명시적 사용)
|
const decipher = crypto.createDecipher("aes-256-cbc", key);
|
||||||
const decipher = crypto.createDecipheriv(this.ALGORITHM, key, iv);
|
|
||||||
let decrypted = decipher.update(encrypted, "hex", "utf8");
|
|
||||||
decrypted += decipher.final("utf8");
|
|
||||||
return decrypted;
|
|
||||||
} catch (newFormatError: unknown) {
|
|
||||||
const errorMessage =
|
|
||||||
newFormatError instanceof Error
|
|
||||||
? newFormatError.message
|
|
||||||
: String(newFormatError);
|
|
||||||
console.warn(
|
|
||||||
"새로운 복호화 방식 실패, 기존 방식으로 시도:",
|
|
||||||
errorMessage
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
// 복호화 실행
|
||||||
// 기존 방식: createDecipher 사용 (하위 호환성)
|
let decrypted = decipher.update(encrypted, "hex", "utf8");
|
||||||
const decipher = crypto.createDecipher("aes-256-cbc", key);
|
decrypted += decipher.final("utf8");
|
||||||
let decrypted = decipher.update(encrypted, "hex", "utf8");
|
|
||||||
decrypted += decipher.final("utf8");
|
return decrypted;
|
||||||
return decrypted;
|
|
||||||
} catch (oldFormatError: unknown) {
|
|
||||||
const oldErrorMessage =
|
|
||||||
oldFormatError instanceof Error
|
|
||||||
? oldFormatError.message
|
|
||||||
: String(oldFormatError);
|
|
||||||
console.error("기존 복호화 방식도 실패:", oldErrorMessage);
|
|
||||||
throw oldFormatError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Password decryption failed:", error);
|
console.error("Password decryption failed:", error);
|
||||||
throw new Error("비밀번호 복호화에 실패했습니다.");
|
throw new Error("비밀번호 복호화에 실패했습니다.");
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# 개발용 백엔드 Dockerfile
|
# 개발용 백엔드 Dockerfile
|
||||||
FROM node:20-alpine
|
FROM node:20-bookworm-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -220,17 +220,13 @@ export const ActionConditionRenderer: React.FC<ActionConditionRendererProps> = (
|
||||||
{condition.tableType === "from" &&
|
{condition.tableType === "from" &&
|
||||||
fromTableColumns.map((column) => (
|
fromTableColumns.map((column) => (
|
||||||
<SelectItem key={column.columnName} value={column.columnName}>
|
<SelectItem key={column.columnName} value={column.columnName}>
|
||||||
{column.displayName && column.displayName !== column.columnName
|
{column.displayName || column.columnLabel || column.columnName}
|
||||||
? column.displayName
|
|
||||||
: column.columnName}
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
{condition.tableType === "to" &&
|
{condition.tableType === "to" &&
|
||||||
toTableColumns.map((column) => (
|
toTableColumns.map((column) => (
|
||||||
<SelectItem key={column.columnName} value={column.columnName}>
|
<SelectItem key={column.columnName} value={column.columnName}>
|
||||||
{column.displayName && column.displayName !== column.columnName
|
{column.displayName || column.columnLabel || column.columnName}
|
||||||
? column.displayName
|
|
||||||
: column.columnName}
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -9,11 +9,6 @@ import { Plus, Trash2 } from "lucide-react";
|
||||||
import { TableInfo, ColumnInfo } from "@/lib/api/dataflow";
|
import { TableInfo, ColumnInfo } from "@/lib/api/dataflow";
|
||||||
import { DataSaveSettings } from "@/types/connectionTypes";
|
import { DataSaveSettings } from "@/types/connectionTypes";
|
||||||
import { InsertFieldMappingPanel } from "./InsertFieldMappingPanel";
|
import { InsertFieldMappingPanel } from "./InsertFieldMappingPanel";
|
||||||
import { ConnectionSelectionPanel } from "./ConnectionSelectionPanel";
|
|
||||||
import { TableSelectionPanel } from "./TableSelectionPanel";
|
|
||||||
import { UpdateFieldMappingPanel } from "./UpdateFieldMappingPanel";
|
|
||||||
import { DeleteConditionPanel } from "./DeleteConditionPanel";
|
|
||||||
import { getActiveConnections, ConnectionInfo } from "@/lib/api/multiConnection";
|
|
||||||
|
|
||||||
interface ActionFieldMappingsProps {
|
interface ActionFieldMappingsProps {
|
||||||
action: DataSaveSettings["actions"][0];
|
action: DataSaveSettings["actions"][0];
|
||||||
|
|
@ -26,8 +21,6 @@ interface ActionFieldMappingsProps {
|
||||||
toTableColumns?: ColumnInfo[];
|
toTableColumns?: ColumnInfo[];
|
||||||
fromTableName?: string;
|
fromTableName?: string;
|
||||||
toTableName?: string;
|
toTableName?: string;
|
||||||
// 🆕 다중 커넥션 지원을 위한 새로운 props
|
|
||||||
enableMultiConnection?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
|
export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
|
||||||
|
|
@ -41,20 +34,8 @@ export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
|
||||||
toTableColumns = [],
|
toTableColumns = [],
|
||||||
fromTableName,
|
fromTableName,
|
||||||
toTableName,
|
toTableName,
|
||||||
enableMultiConnection = false,
|
|
||||||
}) => {
|
}) => {
|
||||||
// 🆕 다중 커넥션 상태 관리
|
// INSERT 액션일 때는 새로운 패널 사용
|
||||||
const [fromConnectionId, setFromConnectionId] = useState<number | undefined>(action.fromConnection?.connectionId);
|
|
||||||
const [toConnectionId, setToConnectionId] = useState<number | undefined>(action.toConnection?.connectionId);
|
|
||||||
const [selectedFromTable, setSelectedFromTable] = useState<string | undefined>(action.fromTable || fromTableName);
|
|
||||||
const [selectedToTable, setSelectedToTable] = useState<string | undefined>(action.targetTable || toTableName);
|
|
||||||
|
|
||||||
// 다중 커넥션이 활성화된 경우 새로운 UI 렌더링
|
|
||||||
if (enableMultiConnection) {
|
|
||||||
return renderMultiConnectionUI();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 기존 INSERT 액션 처리 (단일 커넥션)
|
|
||||||
if (action.actionType === "insert" && fromTableColumns.length > 0 && toTableColumns.length > 0) {
|
if (action.actionType === "insert" && fromTableColumns.length > 0 && toTableColumns.length > 0) {
|
||||||
return (
|
return (
|
||||||
<InsertFieldMappingPanel
|
<InsertFieldMappingPanel
|
||||||
|
|
@ -69,135 +50,6 @@ export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 다중 커넥션 UI 렌더링 함수
|
|
||||||
function renderMultiConnectionUI() {
|
|
||||||
const hasConnectionsSelected = fromConnectionId !== undefined && toConnectionId !== undefined;
|
|
||||||
const hasTablesSelected = selectedFromTable && selectedToTable;
|
|
||||||
|
|
||||||
// 커넥션 변경 핸들러
|
|
||||||
const handleFromConnectionChange = (connectionId: number) => {
|
|
||||||
setFromConnectionId(connectionId);
|
|
||||||
setSelectedFromTable(undefined); // 테이블 선택 초기화
|
|
||||||
updateActionConnection("fromConnection", connectionId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToConnectionChange = (connectionId: number) => {
|
|
||||||
setToConnectionId(connectionId);
|
|
||||||
setSelectedToTable(undefined); // 테이블 선택 초기화
|
|
||||||
updateActionConnection("toConnection", connectionId);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 테이블 변경 핸들러
|
|
||||||
const handleFromTableChange = (tableName: string) => {
|
|
||||||
setSelectedFromTable(tableName);
|
|
||||||
updateActionTable("fromTable", tableName);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToTableChange = (tableName: string) => {
|
|
||||||
setSelectedToTable(tableName);
|
|
||||||
updateActionTable("targetTable", tableName);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 액션 커넥션 정보 업데이트
|
|
||||||
const updateActionConnection = (type: "fromConnection" | "toConnection", connectionId: number) => {
|
|
||||||
const newActions = [...settings.actions];
|
|
||||||
if (!newActions[actionIndex][type]) {
|
|
||||||
newActions[actionIndex][type] = {};
|
|
||||||
}
|
|
||||||
newActions[actionIndex][type]!.connectionId = connectionId;
|
|
||||||
onSettingsChange({ ...settings, actions: newActions });
|
|
||||||
};
|
|
||||||
|
|
||||||
// 액션 테이블 정보 업데이트
|
|
||||||
const updateActionTable = (type: "fromTable" | "targetTable", tableName: string) => {
|
|
||||||
const newActions = [...settings.actions];
|
|
||||||
newActions[actionIndex][type] = tableName;
|
|
||||||
onSettingsChange({ ...settings, actions: newActions });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* 1단계: 커넥션 선택 */}
|
|
||||||
<ConnectionSelectionPanel
|
|
||||||
fromConnectionId={fromConnectionId}
|
|
||||||
toConnectionId={toConnectionId}
|
|
||||||
onFromConnectionChange={handleFromConnectionChange}
|
|
||||||
onToConnectionChange={handleToConnectionChange}
|
|
||||||
actionType={action.actionType}
|
|
||||||
allowSameConnection={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 2단계: 테이블 선택 */}
|
|
||||||
{hasConnectionsSelected && (
|
|
||||||
<TableSelectionPanel
|
|
||||||
fromConnectionId={fromConnectionId}
|
|
||||||
toConnectionId={toConnectionId}
|
|
||||||
selectedFromTable={selectedFromTable}
|
|
||||||
selectedToTable={selectedToTable}
|
|
||||||
onFromTableChange={handleFromTableChange}
|
|
||||||
onToTableChange={handleToTableChange}
|
|
||||||
actionType={action.actionType}
|
|
||||||
allowSameTable={true}
|
|
||||||
showSameTableWarning={true}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 3단계: 액션 타입별 매핑/조건 설정 */}
|
|
||||||
{hasTablesSelected && renderActionSpecificPanel()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 액션 타입별 패널 렌더링
|
|
||||||
function renderActionSpecificPanel() {
|
|
||||||
switch (action.actionType) {
|
|
||||||
case "insert":
|
|
||||||
return (
|
|
||||||
<InsertFieldMappingPanel
|
|
||||||
action={action}
|
|
||||||
actionIndex={actionIndex}
|
|
||||||
settings={settings}
|
|
||||||
onSettingsChange={onSettingsChange}
|
|
||||||
fromConnectionId={fromConnectionId}
|
|
||||||
toConnectionId={toConnectionId}
|
|
||||||
fromTableName={selectedFromTable}
|
|
||||||
toTableName={selectedToTable}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "update":
|
|
||||||
return (
|
|
||||||
<UpdateFieldMappingPanel
|
|
||||||
action={action}
|
|
||||||
actionIndex={actionIndex}
|
|
||||||
settings={settings}
|
|
||||||
onSettingsChange={onSettingsChange}
|
|
||||||
fromConnectionId={fromConnectionId}
|
|
||||||
toConnectionId={toConnectionId}
|
|
||||||
fromTableName={selectedFromTable}
|
|
||||||
toTableName={selectedToTable}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "delete":
|
|
||||||
return (
|
|
||||||
<DeleteConditionPanel
|
|
||||||
action={action}
|
|
||||||
actionIndex={actionIndex}
|
|
||||||
settings={settings}
|
|
||||||
onSettingsChange={onSettingsChange}
|
|
||||||
fromConnectionId={fromConnectionId}
|
|
||||||
toConnectionId={toConnectionId}
|
|
||||||
fromTableName={selectedFromTable}
|
|
||||||
toTableName={selectedToTable}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const addFieldMapping = () => {
|
const addFieldMapping = () => {
|
||||||
const newActions = [...settings.actions];
|
const newActions = [...settings.actions];
|
||||||
newActions[actionIndex].fieldMappings.push({
|
newActions[actionIndex].fieldMappings.push({
|
||||||
|
|
@ -302,9 +154,7 @@ export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
|
||||||
tableColumnsCache[mapping.sourceTable]?.map((column) => (
|
tableColumnsCache[mapping.sourceTable]?.map((column) => (
|
||||||
<SelectItem key={column.columnName} value={column.columnName}>
|
<SelectItem key={column.columnName} value={column.columnName}>
|
||||||
<div className="truncate" title={column.columnName}>
|
<div className="truncate" title={column.columnName}>
|
||||||
{column.displayName && column.displayName !== column.columnName
|
{column.displayName || column.columnLabel || column.columnName}
|
||||||
? column.displayName
|
|
||||||
: column.columnName}
|
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
|
@ -350,9 +200,7 @@ export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
|
||||||
tableColumnsCache[mapping.targetTable]?.map((column) => (
|
tableColumnsCache[mapping.targetTable]?.map((column) => (
|
||||||
<SelectItem key={column.columnName} value={column.columnName}>
|
<SelectItem key={column.columnName} value={column.columnName}>
|
||||||
<div className="truncate" title={column.columnName}>
|
<div className="truncate" title={column.columnName}>
|
||||||
{column.displayName && column.displayName !== column.columnName
|
{column.displayName || column.columnLabel || column.columnName}
|
||||||
? column.displayName
|
|
||||||
: column.columnName}
|
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -90,9 +90,7 @@ export const ActionSplitConfig: React.FC<ActionSplitConfigProps> = ({
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{fromTableColumns.map((column) => (
|
{fromTableColumns.map((column) => (
|
||||||
<SelectItem key={column.columnName} value={column.columnName}>
|
<SelectItem key={column.columnName} value={column.columnName}>
|
||||||
{column.displayName && column.displayName !== column.columnName
|
{column.displayName || column.columnLabel || column.columnName}
|
||||||
? column.displayName
|
|
||||||
: column.columnName}
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
@ -119,9 +117,7 @@ export const ActionSplitConfig: React.FC<ActionSplitConfigProps> = ({
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{toTableColumns.map((column) => (
|
{toTableColumns.map((column) => (
|
||||||
<SelectItem key={column.columnName} value={column.columnName}>
|
<SelectItem key={column.columnName} value={column.columnName}>
|
||||||
{column.displayName && column.displayName !== column.columnName
|
{column.displayName || column.columnLabel || column.columnName}
|
||||||
? column.displayName
|
|
||||||
: column.columnName}
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
|
||||||
|
|
@ -200,9 +200,7 @@ export const ColumnTableSection: React.FC<ColumnTableSectionProps> = ({
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="truncate font-medium">
|
<span className="truncate font-medium">
|
||||||
{column.displayName && column.displayName !== column.columnName
|
{column.displayName || column.columnLabel || column.columnName}
|
||||||
? column.displayName
|
|
||||||
: column.columnName}
|
|
||||||
</span>
|
</span>
|
||||||
{isSelected && <span className="flex-shrink-0 text-blue-500">●</span>}
|
{isSelected && <span className="flex-shrink-0 text-blue-500">●</span>}
|
||||||
{isMapped && <span className="flex-shrink-0 text-green-500">✓</span>}
|
{isMapped && <span className="flex-shrink-0 text-green-500">✓</span>}
|
||||||
|
|
@ -270,9 +268,7 @@ export const ColumnTableSection: React.FC<ColumnTableSectionProps> = ({
|
||||||
<div className="flex min-w-0 flex-col justify-between">
|
<div className="flex min-w-0 flex-col justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="truncate font-medium">
|
<span className="truncate font-medium">
|
||||||
{column.displayName && column.displayName !== column.columnName
|
{column.displayName || column.columnLabel || column.columnName}
|
||||||
? column.displayName
|
|
||||||
: column.columnName}
|
|
||||||
</span>
|
</span>
|
||||||
{isSelected && <span className="flex-shrink-0 text-green-500">●</span>}
|
{isSelected && <span className="flex-shrink-0 text-green-500">●</span>}
|
||||||
{oppositeSelectedColumn && !isTypeCompatible && (
|
{oppositeSelectedColumn && !isTypeCompatible && (
|
||||||
|
|
|
||||||
|
|
@ -1,286 +0,0 @@
|
||||||
/**
|
|
||||||
* 커넥션 선택 패널
|
|
||||||
* 제어관리에서 FROM/TO 커넥션을 선택할 수 있는 컴포넌트
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
||||||
import { AlertCircle, Database, ExternalLink } from "lucide-react";
|
|
||||||
import { getActiveConnections, ConnectionInfo } from "@/lib/api/multiConnection";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
|
|
||||||
export interface ConnectionSelectionPanelProps {
|
|
||||||
fromConnectionId?: number;
|
|
||||||
toConnectionId?: number;
|
|
||||||
onFromConnectionChange: (connectionId: number) => void;
|
|
||||||
onToConnectionChange: (connectionId: number) => void;
|
|
||||||
actionType: "insert" | "update" | "delete";
|
|
||||||
// 🆕 자기 자신 테이블 작업 지원
|
|
||||||
allowSameConnection?: boolean;
|
|
||||||
currentConnectionId?: number; // 현재 메인 DB 커넥션
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ConnectionSelectionPanel: React.FC<ConnectionSelectionPanelProps> = ({
|
|
||||||
fromConnectionId,
|
|
||||||
toConnectionId,
|
|
||||||
onFromConnectionChange,
|
|
||||||
onToConnectionChange,
|
|
||||||
actionType,
|
|
||||||
allowSameConnection = true,
|
|
||||||
currentConnectionId = 0,
|
|
||||||
disabled = false,
|
|
||||||
}) => {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [availableConnections, setAvailableConnections] = useState<ConnectionInfo[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
// 커넥션 목록 로드 (한 번만 실행)
|
|
||||||
useEffect(() => {
|
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
const loadConnections = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
console.log("🔍 커넥션 목록 로드 시작...");
|
|
||||||
const connections = await getActiveConnections();
|
|
||||||
|
|
||||||
if (isMounted) {
|
|
||||||
console.log("✅ 커넥션 목록 로드 성공:", connections);
|
|
||||||
setAvailableConnections(Array.isArray(connections) ? connections : []);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (isMounted) {
|
|
||||||
console.error("❌ 커넥션 목록 로드 실패:", error);
|
|
||||||
console.error("Error details:", {
|
|
||||||
message: error instanceof Error ? error.message : String(error),
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
response: error && typeof error === "object" && "response" in error ? error.response : undefined,
|
|
||||||
});
|
|
||||||
toast({
|
|
||||||
title: "커넥션 로드 실패",
|
|
||||||
description: `활성 커넥션 목록을 불러오는데 실패했습니다. ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (isMounted) {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 컴포넌트가 마운트된 후 1초 딜레이로 API 호출 (Rate Limiting 방지)
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
if (isMounted) {
|
|
||||||
loadConnections();
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
};
|
|
||||||
}, []); // 의존성 배열을 빈 배열로 수정
|
|
||||||
|
|
||||||
// 액션 타입별 라벨 정의
|
|
||||||
const getConnectionLabels = () => {
|
|
||||||
switch (actionType) {
|
|
||||||
case "insert":
|
|
||||||
return {
|
|
||||||
from: {
|
|
||||||
title: "소스 데이터베이스 연결",
|
|
||||||
desc: "데이터를 가져올 데이터베이스 연결을 선택하세요",
|
|
||||||
},
|
|
||||||
to: {
|
|
||||||
title: "대상 데이터베이스 연결",
|
|
||||||
desc: "데이터를 저장할 데이터베이스 연결을 선택하세요",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
case "update":
|
|
||||||
return {
|
|
||||||
from: {
|
|
||||||
title: "조건 확인 데이터베이스",
|
|
||||||
desc: "업데이트 조건을 확인할 데이터베이스 연결을 선택하세요 (자기 자신 가능)",
|
|
||||||
},
|
|
||||||
to: {
|
|
||||||
title: "업데이트 대상 데이터베이스",
|
|
||||||
desc: "데이터를 업데이트할 데이터베이스 연결을 선택하세요 (자기 자신 가능)",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
case "delete":
|
|
||||||
return {
|
|
||||||
from: {
|
|
||||||
title: "조건 확인 데이터베이스",
|
|
||||||
desc: "삭제 조건을 확인할 데이터베이스 연결을 선택하세요 (자기 자신 가능)",
|
|
||||||
},
|
|
||||||
to: {
|
|
||||||
title: "삭제 대상 데이터베이스",
|
|
||||||
desc: "데이터를 삭제할 데이터베이스 연결을 선택하세요 (자기 자신 가능)",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 🆕 자기 자신 테이블 작업 시 경고 메시지
|
|
||||||
const getSameConnectionWarning = () => {
|
|
||||||
if (fromConnectionId === toConnectionId && fromConnectionId !== undefined) {
|
|
||||||
switch (actionType) {
|
|
||||||
case "update":
|
|
||||||
return "⚠️ 같은 데이터베이스에서 UPDATE 작업을 수행합니다. 조건을 신중히 설정하세요.";
|
|
||||||
case "delete":
|
|
||||||
return "🚨 같은 데이터베이스에서 DELETE 작업을 수행합니다. 데이터 손실에 주의하세요.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const labels = getConnectionLabels();
|
|
||||||
const warningMessage = getSameConnectionWarning();
|
|
||||||
|
|
||||||
// 커넥션 선택 핸들러
|
|
||||||
const handleFromConnectionChange = (value: string) => {
|
|
||||||
const connectionId = parseInt(value);
|
|
||||||
onFromConnectionChange(connectionId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToConnectionChange = (value: string) => {
|
|
||||||
const connectionId = parseInt(value);
|
|
||||||
onToConnectionChange(connectionId);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 커넥션 아이템 렌더링
|
|
||||||
const renderConnectionItem = (connection: ConnectionInfo) => (
|
|
||||||
<SelectItem key={connection.id} value={connection.id.toString()}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{connection.id === 0 ? (
|
|
||||||
<Badge variant="default" className="text-xs">
|
|
||||||
<Database className="mr-1 h-3 w-3" />
|
|
||||||
현재 DB
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
<ExternalLink className="mr-1 h-3 w-3" />
|
|
||||||
{connection.db_type?.toUpperCase()}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
<span className="truncate">{connection.connection_name}</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<div className="h-4 w-4 animate-pulse rounded bg-gray-300" />
|
|
||||||
<div className="h-4 w-32 animate-pulse rounded bg-gray-300" />
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="h-10 w-full animate-pulse rounded bg-gray-200" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<div className="h-4 w-4 animate-pulse rounded bg-gray-300" />
|
|
||||||
<div className="h-4 w-32 animate-pulse rounded bg-gray-300" />
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="h-10 w-full animate-pulse rounded bg-gray-200" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-6">
|
|
||||||
{/* FROM 커넥션 선택 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Database className="h-4 w-4" />
|
|
||||||
{labels.from.title}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>{labels.from.desc}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Select
|
|
||||||
value={fromConnectionId?.toString() || ""}
|
|
||||||
onValueChange={handleFromConnectionChange}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="커넥션을 선택하세요" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{Array.isArray(availableConnections) ? availableConnections.map(renderConnectionItem) : []}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* TO 커넥션 선택 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Database className="h-4 w-4" />
|
|
||||||
{labels.to.title}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>{labels.to.desc}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Select
|
|
||||||
value={toConnectionId?.toString() || ""}
|
|
||||||
onValueChange={handleToConnectionChange}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="커넥션을 선택하세요" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{Array.isArray(availableConnections) ? availableConnections.map(renderConnectionItem) : []}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 🆕 자기 자신 테이블 작업 시 경고 */}
|
|
||||||
{warningMessage && (
|
|
||||||
<Alert variant={actionType === "delete" ? "destructive" : "default"}>
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertTitle>주의사항</AlertTitle>
|
|
||||||
<AlertDescription>{warningMessage}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 연결 상태 표시 */}
|
|
||||||
{fromConnectionId !== undefined && toConnectionId !== undefined && (
|
|
||||||
<div className="text-muted-foreground text-sm">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>연결 상태:</span>
|
|
||||||
<Badge variant="secondary">
|
|
||||||
{availableConnections.find((c) => c.id === fromConnectionId)?.connection_name || "Unknown"}
|
|
||||||
</Badge>
|
|
||||||
<span>→</span>
|
|
||||||
<Badge variant="secondary">
|
|
||||||
{availableConnections.find((c) => c.id === toConnectionId)?.connection_name || "Unknown"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -195,25 +195,30 @@ export const DataSaveSettings: React.FC<DataSaveSettingsProps> = ({
|
||||||
toTableColumns={toTableColumns}
|
toTableColumns={toTableColumns}
|
||||||
fromTableName={fromTableName}
|
fromTableName={fromTableName}
|
||||||
toTableName={toTableName}
|
toTableName={toTableName}
|
||||||
enableMultiConnection={true}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* DELETE 액션일 때 다중 커넥션 지원 */}
|
{/* DELETE 액션일 때 안내 메시지 */}
|
||||||
{action.actionType === "delete" && (
|
{action.actionType === "delete" && (
|
||||||
<ActionFieldMappings
|
<div className="mt-3">
|
||||||
action={action}
|
<div className="rounded border border-blue-200 bg-blue-50 p-3 text-xs text-blue-700">
|
||||||
actionIndex={actionIndex}
|
<div className="flex items-start gap-2">
|
||||||
settings={settings}
|
<span>ℹ️</span>
|
||||||
onSettingsChange={onSettingsChange}
|
<div>
|
||||||
availableTables={availableTables}
|
<div className="font-medium">DELETE 액션 정보</div>
|
||||||
tableColumnsCache={tableColumnsCache}
|
<div className="mt-1">
|
||||||
fromTableColumns={fromTableColumns}
|
DELETE 액션은 <strong>실행조건만</strong> 필요합니다.
|
||||||
toTableColumns={toTableColumns}
|
<br />
|
||||||
fromTableName={fromTableName}
|
• 데이터 분할 설정: 불필요 (삭제 작업에는 분할이 의미 없음)
|
||||||
toTableName={toTableName}
|
<br />
|
||||||
enableMultiConnection={true}
|
• 필드 매핑: 불필요 (조건에 맞는 데이터를 통째로 삭제)
|
||||||
/>
|
<br />
|
||||||
|
위에서 설정한 실행조건에 맞는 모든 데이터가 삭제됩니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,584 +0,0 @@
|
||||||
/**
|
|
||||||
* DELETE 조건 패널
|
|
||||||
* DELETE 액션용 조건 설정 및 안전장치 컴포넌트
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Plus, X, Search, AlertCircle, Shield, Trash2 } from "lucide-react";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { ColumnInfo, getColumnsFromConnection } from "@/lib/api/multiConnection";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
|
|
||||||
export interface DeleteCondition {
|
|
||||||
id: string;
|
|
||||||
fromColumn: string;
|
|
||||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN" | "NOT IN" | "EXISTS" | "NOT EXISTS";
|
|
||||||
value: string | string[];
|
|
||||||
logicalOperator?: "AND" | "OR";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeleteWhereCondition {
|
|
||||||
id: string;
|
|
||||||
toColumn: string;
|
|
||||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN" | "NOT IN";
|
|
||||||
valueSource: "from_column" | "static" | "condition_result";
|
|
||||||
fromColumn?: string;
|
|
||||||
staticValue?: string;
|
|
||||||
logicalOperator?: "AND" | "OR";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeleteSafetySettings {
|
|
||||||
maxDeleteCount: number;
|
|
||||||
requireConfirmation: boolean;
|
|
||||||
dryRunFirst: boolean;
|
|
||||||
logAllDeletes: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeleteConditionPanelProps {
|
|
||||||
action: any;
|
|
||||||
actionIndex: number;
|
|
||||||
settings: any;
|
|
||||||
onSettingsChange: (settings: any) => void;
|
|
||||||
fromConnectionId?: number;
|
|
||||||
toConnectionId?: number;
|
|
||||||
fromTableName?: string;
|
|
||||||
toTableName?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DeleteConditionPanel: React.FC<DeleteConditionPanelProps> = ({
|
|
||||||
action,
|
|
||||||
actionIndex,
|
|
||||||
settings,
|
|
||||||
onSettingsChange,
|
|
||||||
fromConnectionId,
|
|
||||||
toConnectionId,
|
|
||||||
fromTableName,
|
|
||||||
toTableName,
|
|
||||||
disabled = false,
|
|
||||||
}) => {
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
// 상태 관리
|
|
||||||
const [fromTableColumns, setFromTableColumns] = useState<ColumnInfo[]>([]);
|
|
||||||
const [toTableColumns, setToTableColumns] = useState<ColumnInfo[]>([]);
|
|
||||||
const [deleteConditions, setDeleteConditions] = useState<DeleteCondition[]>([]);
|
|
||||||
const [whereConditions, setWhereConditions] = useState<DeleteWhereCondition[]>([]);
|
|
||||||
const [safetySettings, setSafetySettings] = useState<DeleteSafetySettings>({
|
|
||||||
maxDeleteCount: 100,
|
|
||||||
requireConfirmation: true,
|
|
||||||
dryRunFirst: true,
|
|
||||||
logAllDeletes: true,
|
|
||||||
});
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
// 검색 상태
|
|
||||||
const [fromColumnSearch, setFromColumnSearch] = useState("");
|
|
||||||
const [toColumnSearch, setToColumnSearch] = useState("");
|
|
||||||
|
|
||||||
// 컬럼 정보 로드
|
|
||||||
useEffect(() => {
|
|
||||||
if (fromConnectionId !== undefined && fromTableName) {
|
|
||||||
loadColumnInfo(fromConnectionId, fromTableName, setFromTableColumns, "FROM");
|
|
||||||
}
|
|
||||||
}, [fromConnectionId, fromTableName]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (toConnectionId !== undefined && toTableName) {
|
|
||||||
loadColumnInfo(toConnectionId, toTableName, setToTableColumns, "TO");
|
|
||||||
}
|
|
||||||
}, [toConnectionId, toTableName]);
|
|
||||||
|
|
||||||
// 컬럼 정보 로드 함수
|
|
||||||
const loadColumnInfo = async (
|
|
||||||
connectionId: number,
|
|
||||||
tableName: string,
|
|
||||||
setColumns: React.Dispatch<React.SetStateAction<ColumnInfo[]>>,
|
|
||||||
type: "FROM" | "TO",
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const columns = await getColumnsFromConnection(connectionId, tableName);
|
|
||||||
setColumns(columns);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`${type} 컬럼 정보 로드 실패:`, error);
|
|
||||||
toast({
|
|
||||||
title: "컬럼 로드 실패",
|
|
||||||
description: `${type} 테이블의 컬럼 정보를 불러오는데 실패했습니다.`,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 컬럼 필터링
|
|
||||||
const getFilteredColumns = (columns: ColumnInfo[], searchTerm: string) => {
|
|
||||||
if (!searchTerm.trim()) return columns;
|
|
||||||
|
|
||||||
const term = searchTerm.toLowerCase();
|
|
||||||
return columns.filter(
|
|
||||||
(col) => col.columnName.toLowerCase().includes(term) || col.displayName.toLowerCase().includes(term),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// DELETE 트리거 조건 추가
|
|
||||||
const addDeleteCondition = () => {
|
|
||||||
const newCondition: DeleteCondition = {
|
|
||||||
id: `delete_condition_${Date.now()}`,
|
|
||||||
fromColumn: "",
|
|
||||||
operator: "=",
|
|
||||||
value: "",
|
|
||||||
logicalOperator: deleteConditions.length > 0 ? "AND" : undefined,
|
|
||||||
};
|
|
||||||
setDeleteConditions([...deleteConditions, newCondition]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// DELETE 트리거 조건 제거
|
|
||||||
const removeDeleteCondition = (id: string) => {
|
|
||||||
setDeleteConditions(deleteConditions.filter((c) => c.id !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
// DELETE 트리거 조건 수정
|
|
||||||
const updateDeleteCondition = (id: string, field: keyof DeleteCondition, value: any) => {
|
|
||||||
setDeleteConditions(deleteConditions.map((c) => (c.id === id ? { ...c, [field]: value } : c)));
|
|
||||||
};
|
|
||||||
|
|
||||||
// WHERE 조건 추가
|
|
||||||
const addWhereCondition = () => {
|
|
||||||
const newCondition: DeleteWhereCondition = {
|
|
||||||
id: `where_${Date.now()}`,
|
|
||||||
toColumn: "",
|
|
||||||
operator: "=",
|
|
||||||
valueSource: "from_column",
|
|
||||||
fromColumn: "",
|
|
||||||
staticValue: "",
|
|
||||||
logicalOperator: whereConditions.length > 0 ? "AND" : undefined,
|
|
||||||
};
|
|
||||||
setWhereConditions([...whereConditions, newCondition]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// WHERE 조건 제거
|
|
||||||
const removeWhereCondition = (id: string) => {
|
|
||||||
setWhereConditions(whereConditions.filter((c) => c.id !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
// WHERE 조건 수정
|
|
||||||
const updateWhereCondition = (id: string, field: keyof DeleteWhereCondition, value: any) => {
|
|
||||||
setWhereConditions(whereConditions.map((c) => (c.id === id ? { ...c, [field]: value } : c)));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 안전장치 설정 수정
|
|
||||||
const updateSafetySettings = (field: keyof DeleteSafetySettings, value: any) => {
|
|
||||||
setSafetySettings((prev) => ({ ...prev, [field]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 자기 자신 테이블 작업 경고
|
|
||||||
const getSelfTableWarning = () => {
|
|
||||||
if (fromConnectionId === toConnectionId && fromTableName === toTableName) {
|
|
||||||
return "🚨 자기 자신 테이블 DELETE 작업입니다. 매우 위험할 수 있으므로 신중히 설정하세요.";
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 위험한 조건 검사
|
|
||||||
const getDangerousConditionsWarning = () => {
|
|
||||||
const dangerousOperators = ["!=", "NOT IN", "NOT EXISTS"];
|
|
||||||
const hasDangerousConditions = deleteConditions.some((condition) =>
|
|
||||||
dangerousOperators.includes(condition.operator),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasDangerousConditions) {
|
|
||||||
return "⚠️ 부정 조건(!=, NOT IN, NOT EXISTS)은 예상보다 많은 데이터를 삭제할 수 있습니다.";
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const warningMessage = getSelfTableWarning();
|
|
||||||
const dangerousWarning = getDangerousConditionsWarning();
|
|
||||||
const filteredFromColumns = getFilteredColumns(fromTableColumns, fromColumnSearch);
|
|
||||||
const filteredToColumns = getFilteredColumns(toTableColumns, toColumnSearch);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* 경고 메시지 */}
|
|
||||||
{warningMessage && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>{warningMessage}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{dangerousWarning && (
|
|
||||||
<Alert variant="default">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>{dangerousWarning}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* DELETE 트리거 조건 설정 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">🔥 삭제 트리거 조건</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
FROM 테이블에서 어떤 조건을 만족하는 데이터가 있을 때 TO 테이블에서 삭제를 실행할지 설정하세요
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{/* 검색 필드 */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium">FROM 컬럼 검색</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
|
||||||
<Input
|
|
||||||
placeholder="컬럼 검색..."
|
|
||||||
value={fromColumnSearch}
|
|
||||||
onChange={(e) => setFromColumnSearch(e.target.value)}
|
|
||||||
className="pl-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 삭제 트리거 조건 리스트 */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
{deleteConditions.map((condition, index) => (
|
|
||||||
<div key={condition.id} className="flex items-center gap-2 rounded-lg border p-3">
|
|
||||||
{index > 0 && (
|
|
||||||
<Select
|
|
||||||
value={condition.logicalOperator || "AND"}
|
|
||||||
onValueChange={(value) => updateDeleteCondition(condition.id, "logicalOperator", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-20">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="AND">AND</SelectItem>
|
|
||||||
<SelectItem value="OR">OR</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={condition.fromColumn}
|
|
||||||
onValueChange={(value) => updateDeleteCondition(condition.id, "fromColumn", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-48">
|
|
||||||
<SelectValue placeholder="FROM 컬럼" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{filteredFromColumns.map((col) => (
|
|
||||||
<SelectItem key={col.columnName} value={col.columnName}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>{col.columnName}</span>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{col.dataType}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={condition.operator}
|
|
||||||
onValueChange={(value) => updateDeleteCondition(condition.id, "operator", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-24">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="=">=</SelectItem>
|
|
||||||
<SelectItem value="!=">
|
|
||||||
<span className="text-red-600">!=</span>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value=">">></SelectItem>
|
|
||||||
<SelectItem value="<"><</SelectItem>
|
|
||||||
<SelectItem value=">=">>=</SelectItem>
|
|
||||||
<SelectItem value="<="><=</SelectItem>
|
|
||||||
<SelectItem value="LIKE">LIKE</SelectItem>
|
|
||||||
<SelectItem value="IN">IN</SelectItem>
|
|
||||||
<SelectItem value="NOT IN">
|
|
||||||
<span className="text-red-600">NOT IN</span>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="EXISTS">EXISTS</SelectItem>
|
|
||||||
<SelectItem value="NOT EXISTS">
|
|
||||||
<span className="text-red-600">NOT EXISTS</span>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder="값"
|
|
||||||
value={condition.value as string}
|
|
||||||
onChange={(e) => updateDeleteCondition(condition.id, "value", e.target.value)}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button variant="outline" size="sm" onClick={() => removeDeleteCondition(condition.id)}>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Button variant="outline" onClick={addDeleteCondition} className="w-full" disabled={disabled}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
삭제 조건 추가
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* DELETE WHERE 조건 설정 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">🎯 삭제 대상 조건</CardTitle>
|
|
||||||
<CardDescription>TO 테이블에서 어떤 레코드를 삭제할지 WHERE 조건을 설정하세요</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{/* 검색 필드 */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium">FROM 컬럼 검색</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
|
||||||
<Input
|
|
||||||
placeholder="컬럼 검색..."
|
|
||||||
value={fromColumnSearch}
|
|
||||||
onChange={(e) => setFromColumnSearch(e.target.value)}
|
|
||||||
className="pl-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium">TO 컬럼 검색</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
|
||||||
<Input
|
|
||||||
placeholder="컬럼 검색..."
|
|
||||||
value={toColumnSearch}
|
|
||||||
onChange={(e) => setToColumnSearch(e.target.value)}
|
|
||||||
className="pl-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* WHERE 조건 리스트 */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
{whereConditions.map((condition, index) => (
|
|
||||||
<div key={condition.id} className="flex items-center gap-2 rounded-lg border p-3">
|
|
||||||
{index > 0 && (
|
|
||||||
<Select
|
|
||||||
value={condition.logicalOperator || "AND"}
|
|
||||||
onValueChange={(value) => updateWhereCondition(condition.id, "logicalOperator", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-20">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="AND">AND</SelectItem>
|
|
||||||
<SelectItem value="OR">OR</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={condition.toColumn}
|
|
||||||
onValueChange={(value) => updateWhereCondition(condition.id, "toColumn", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-48">
|
|
||||||
<SelectValue placeholder="TO 컬럼" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{filteredToColumns.map((col) => (
|
|
||||||
<SelectItem key={col.columnName} value={col.columnName}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>{col.columnName}</span>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{col.dataType}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={condition.operator}
|
|
||||||
onValueChange={(value) => updateWhereCondition(condition.id, "operator", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-20">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="=">=</SelectItem>
|
|
||||||
<SelectItem value="!=">!=</SelectItem>
|
|
||||||
<SelectItem value=">">></SelectItem>
|
|
||||||
<SelectItem value="<"><</SelectItem>
|
|
||||||
<SelectItem value=">=">>=</SelectItem>
|
|
||||||
<SelectItem value="<="><=</SelectItem>
|
|
||||||
<SelectItem value="LIKE">LIKE</SelectItem>
|
|
||||||
<SelectItem value="IN">IN</SelectItem>
|
|
||||||
<SelectItem value="NOT IN">NOT IN</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={condition.valueSource}
|
|
||||||
onValueChange={(value) => updateWhereCondition(condition.id, "valueSource", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-32">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="from_column">FROM 컬럼</SelectItem>
|
|
||||||
<SelectItem value="static">고정값</SelectItem>
|
|
||||||
<SelectItem value="condition_result">조건 결과</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{condition.valueSource === "from_column" && (
|
|
||||||
<Select
|
|
||||||
value={condition.fromColumn || ""}
|
|
||||||
onValueChange={(value) => updateWhereCondition(condition.id, "fromColumn", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-48">
|
|
||||||
<SelectValue placeholder="FROM 컬럼 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{filteredFromColumns.map((col) => (
|
|
||||||
<SelectItem key={col.columnName} value={col.columnName}>
|
|
||||||
{col.columnName}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{condition.valueSource === "static" && (
|
|
||||||
<Input
|
|
||||||
placeholder="고정값"
|
|
||||||
value={condition.staticValue || ""}
|
|
||||||
onChange={(e) => updateWhereCondition(condition.id, "staticValue", e.target.value)}
|
|
||||||
className="w-32"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button variant="outline" size="sm" onClick={() => removeWhereCondition(condition.id)}>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Button variant="outline" onClick={addWhereCondition} className="w-full" disabled={disabled}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
WHERE 조건 추가
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{whereConditions.length === 0 && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>안전을 위해 DELETE 작업에는 최소 하나 이상의 WHERE 조건이 필요합니다.</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* 안전장치 설정 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Shield className="h-4 w-4" />
|
|
||||||
삭제 안전장치
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>예상치 못한 대량 삭제를 방지하기 위한 안전장치를 설정하세요</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
{/* 최대 삭제 개수 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="maxDeleteCount">최대 삭제 개수</Label>
|
|
||||||
<Input
|
|
||||||
id="maxDeleteCount"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="10000"
|
|
||||||
value={safetySettings.maxDeleteCount}
|
|
||||||
onChange={(e) => updateSafetySettings("maxDeleteCount", parseInt(e.target.value))}
|
|
||||||
className="w-32"
|
|
||||||
/>
|
|
||||||
<p className="text-muted-foreground text-sm">한 번에 삭제할 수 있는 최대 레코드 수를 제한합니다.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 안전장치 옵션들 */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label htmlFor="requireConfirmation">삭제 확인 요구</Label>
|
|
||||||
<p className="text-muted-foreground text-sm">삭제 실행 전 추가 확인을 요구합니다.</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id="requireConfirmation"
|
|
||||||
checked={safetySettings.requireConfirmation}
|
|
||||||
onCheckedChange={(checked) => updateSafetySettings("requireConfirmation", checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label htmlFor="dryRunFirst">Dry Run 먼저 실행</Label>
|
|
||||||
<p className="text-muted-foreground text-sm">실제 삭제 전에 삭제 대상 개수를 먼저 확인합니다.</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id="dryRunFirst"
|
|
||||||
checked={safetySettings.dryRunFirst}
|
|
||||||
onCheckedChange={(checked) => updateSafetySettings("dryRunFirst", checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label htmlFor="logAllDeletes">모든 삭제 기록</Label>
|
|
||||||
<p className="text-muted-foreground text-sm">삭제된 모든 레코드를 로그에 기록합니다.</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id="logAllDeletes"
|
|
||||||
checked={safetySettings.logAllDeletes}
|
|
||||||
onCheckedChange={(checked) => updateSafetySettings("logAllDeletes", checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 자기 자신 테이블 추가 안전장치 */}
|
|
||||||
{fromConnectionId === toConnectionId && fromTableName === toTableName && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
<AlertDescription className="space-y-2">
|
|
||||||
<div className="font-medium">자기 자신 테이블 삭제 시 강화된 안전장치:</div>
|
|
||||||
<ul className="list-inside list-disc space-y-1 text-sm">
|
|
||||||
<li>최대 삭제 개수가 자동으로 {Math.min(safetySettings.maxDeleteCount, 10)}개로 제한됩니다</li>
|
|
||||||
<li>부정 조건(!=, NOT IN, NOT EXISTS) 사용이 금지됩니다</li>
|
|
||||||
<li>WHERE 조건을 2개 이상 설정하는 것을 강력히 권장합니다</li>
|
|
||||||
</ul>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -8,24 +8,16 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { ColumnInfo } from "@/lib/api/dataflow";
|
import { ColumnInfo } from "@/lib/api/dataflow";
|
||||||
import { DataSaveSettings } from "@/types/connectionTypes";
|
import { DataSaveSettings } from "@/types/connectionTypes";
|
||||||
import { ColumnTableSection } from "./ColumnTableSection";
|
import { ColumnTableSection } from "./ColumnTableSection";
|
||||||
import {
|
|
||||||
getColumnsFromConnection,
|
|
||||||
getTablesFromConnection,
|
|
||||||
ColumnInfo as MultiColumnInfo,
|
|
||||||
} from "@/lib/api/multiConnection";
|
|
||||||
|
|
||||||
interface InsertFieldMappingPanelProps {
|
interface InsertFieldMappingPanelProps {
|
||||||
action: DataSaveSettings["actions"][0];
|
action: DataSaveSettings["actions"][0];
|
||||||
actionIndex: number;
|
actionIndex: number;
|
||||||
settings: DataSaveSettings;
|
settings: DataSaveSettings;
|
||||||
onSettingsChange: (settings: DataSaveSettings) => void;
|
onSettingsChange: (settings: DataSaveSettings) => void;
|
||||||
fromTableColumns?: ColumnInfo[];
|
fromTableColumns: ColumnInfo[];
|
||||||
toTableColumns?: ColumnInfo[];
|
toTableColumns: ColumnInfo[];
|
||||||
fromTableName?: string;
|
fromTableName?: string;
|
||||||
toTableName?: string;
|
toTableName?: string;
|
||||||
// 다중 커넥션 지원
|
|
||||||
fromConnectionId?: number;
|
|
||||||
toConnectionId?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ColumnMapping {
|
interface ColumnMapping {
|
||||||
|
|
@ -39,26 +31,15 @@ export const InsertFieldMappingPanel: React.FC<InsertFieldMappingPanelProps> = (
|
||||||
actionIndex,
|
actionIndex,
|
||||||
settings,
|
settings,
|
||||||
onSettingsChange,
|
onSettingsChange,
|
||||||
fromTableColumns = [],
|
fromTableColumns,
|
||||||
toTableColumns = [],
|
toTableColumns,
|
||||||
fromTableName,
|
fromTableName,
|
||||||
toTableName,
|
toTableName,
|
||||||
fromConnectionId,
|
|
||||||
toConnectionId,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [selectedFromColumn, setSelectedFromColumn] = useState<string | null>(null);
|
const [selectedFromColumn, setSelectedFromColumn] = useState<string | null>(null);
|
||||||
const [selectedToColumn, setSelectedToColumn] = useState<string | null>(null);
|
const [selectedToColumn, setSelectedToColumn] = useState<string | null>(null);
|
||||||
const [columnMappings, setColumnMappings] = useState<ColumnMapping[]>([]);
|
const [columnMappings, setColumnMappings] = useState<ColumnMapping[]>([]);
|
||||||
|
|
||||||
// 다중 커넥션에서 로드한 컬럼 정보
|
|
||||||
const [multiFromColumns, setMultiFromColumns] = useState<MultiColumnInfo[]>([]);
|
|
||||||
const [multiToColumns, setMultiToColumns] = useState<MultiColumnInfo[]>([]);
|
|
||||||
const [isLoadingColumns, setIsLoadingColumns] = useState(false);
|
|
||||||
|
|
||||||
// 테이블 라벨명 정보
|
|
||||||
const [fromTableDisplayName, setFromTableDisplayName] = useState<string>("");
|
|
||||||
const [toTableDisplayName, setToTableDisplayName] = useState<string>("");
|
|
||||||
|
|
||||||
// 검색 및 필터링 상태 (FROM과 TO 독립적)
|
// 검색 및 필터링 상태 (FROM과 TO 독립적)
|
||||||
const [fromSearchTerm, setFromSearchTerm] = useState("");
|
const [fromSearchTerm, setFromSearchTerm] = useState("");
|
||||||
const [toSearchTerm, setToSearchTerm] = useState("");
|
const [toSearchTerm, setToSearchTerm] = useState("");
|
||||||
|
|
@ -73,84 +54,9 @@ export const InsertFieldMappingPanel: React.FC<InsertFieldMappingPanelProps> = (
|
||||||
const [toShowMappedOnly, setToShowMappedOnly] = useState(false);
|
const [toShowMappedOnly, setToShowMappedOnly] = useState(false);
|
||||||
const [toShowUnmappedOnly, setToShowUnmappedOnly] = useState(false);
|
const [toShowUnmappedOnly, setToShowUnmappedOnly] = useState(false);
|
||||||
|
|
||||||
// 다중 커넥션에서 컬럼 정보 및 테이블 라벨명 로드
|
|
||||||
useEffect(() => {
|
|
||||||
const loadColumnsAndTableInfo = async () => {
|
|
||||||
if (fromConnectionId !== undefined && toConnectionId !== undefined && fromTableName && toTableName) {
|
|
||||||
setIsLoadingColumns(true);
|
|
||||||
try {
|
|
||||||
const [fromCols, toCols, fromTables, toTables] = await Promise.all([
|
|
||||||
getColumnsFromConnection(fromConnectionId, fromTableName),
|
|
||||||
getColumnsFromConnection(toConnectionId, toTableName),
|
|
||||||
getTablesFromConnection(fromConnectionId),
|
|
||||||
getTablesFromConnection(toConnectionId),
|
|
||||||
]);
|
|
||||||
|
|
||||||
setMultiFromColumns(fromCols);
|
|
||||||
setMultiToColumns(toCols);
|
|
||||||
|
|
||||||
// 테이블 라벨명 설정
|
|
||||||
const fromTable = fromTables.find((t) => t.tableName === fromTableName);
|
|
||||||
const toTable = toTables.find((t) => t.tableName === toTableName);
|
|
||||||
|
|
||||||
setFromTableDisplayName(
|
|
||||||
fromTable?.displayName && fromTable.displayName !== fromTable.tableName
|
|
||||||
? fromTable.displayName
|
|
||||||
: fromTableName,
|
|
||||||
);
|
|
||||||
|
|
||||||
setToTableDisplayName(
|
|
||||||
toTable?.displayName && toTable.displayName !== toTable.tableName ? toTable.displayName : toTableName,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("컬럼 정보 및 테이블 정보 로드 실패:", error);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingColumns(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadColumnsAndTableInfo();
|
|
||||||
}, [fromConnectionId, toConnectionId, fromTableName, toTableName]);
|
|
||||||
|
|
||||||
// 사용할 컬럼 데이터 결정 (다중 커넥션 > 기존)
|
|
||||||
const actualFromColumns = useMemo(() => {
|
|
||||||
if (multiFromColumns.length > 0) {
|
|
||||||
return multiFromColumns.map((col) => ({
|
|
||||||
columnName: col.columnName,
|
|
||||||
displayName: col.displayName,
|
|
||||||
dataType: col.dataType,
|
|
||||||
isNullable: col.isNullable,
|
|
||||||
isPrimaryKey: col.isPrimaryKey,
|
|
||||||
defaultValue: col.defaultValue,
|
|
||||||
maxLength: col.maxLength,
|
|
||||||
description: col.description,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return fromTableColumns || [];
|
|
||||||
}, [multiFromColumns.length, fromTableColumns?.length]);
|
|
||||||
|
|
||||||
const actualToColumns = useMemo(() => {
|
|
||||||
if (multiToColumns.length > 0) {
|
|
||||||
return multiToColumns.map((col) => ({
|
|
||||||
columnName: col.columnName,
|
|
||||||
displayName: col.displayName,
|
|
||||||
dataType: col.dataType,
|
|
||||||
isNullable: col.isNullable,
|
|
||||||
isPrimaryKey: col.isPrimaryKey,
|
|
||||||
defaultValue: col.defaultValue,
|
|
||||||
maxLength: col.maxLength,
|
|
||||||
description: col.description,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return toTableColumns || [];
|
|
||||||
}, [multiToColumns.length, toTableColumns?.length]);
|
|
||||||
|
|
||||||
// 기존 매핑 데이터를 columnMappings로 변환
|
// 기존 매핑 데이터를 columnMappings로 변환
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const columnsToUse = multiToColumns.length > 0 ? multiToColumns : toTableColumns || [];
|
const mappings: ColumnMapping[] = toTableColumns.map((toCol) => {
|
||||||
|
|
||||||
const mappings: ColumnMapping[] = columnsToUse.map((toCol) => {
|
|
||||||
const existingMapping = action.fieldMappings.find((mapping) => mapping.targetField === toCol.columnName);
|
const existingMapping = action.fieldMappings.find((mapping) => mapping.targetField === toCol.columnName);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -161,7 +67,7 @@ export const InsertFieldMappingPanel: React.FC<InsertFieldMappingPanelProps> = (
|
||||||
});
|
});
|
||||||
|
|
||||||
setColumnMappings(mappings);
|
setColumnMappings(mappings);
|
||||||
}, [action.fieldMappings, multiToColumns.length, toTableColumns?.length]);
|
}, [action.fieldMappings, toTableColumns]);
|
||||||
|
|
||||||
// columnMappings 변경 시 settings 업데이트
|
// columnMappings 변경 시 settings 업데이트
|
||||||
const updateSettings = (newMappings: ColumnMapping[]) => {
|
const updateSettings = (newMappings: ColumnMapping[]) => {
|
||||||
|
|
@ -303,7 +209,7 @@ export const InsertFieldMappingPanel: React.FC<InsertFieldMappingPanelProps> = (
|
||||||
|
|
||||||
if (!selectedToColumn) return true; // TO가 선택되지 않았으면 모든 FROM 클릭 가능
|
if (!selectedToColumn) return true; // TO가 선택되지 않았으면 모든 FROM 클릭 가능
|
||||||
|
|
||||||
const toColumn = actualToColumns.find((col) => col.columnName === selectedToColumn);
|
const toColumn = toTableColumns.find((col) => col.columnName === selectedToColumn);
|
||||||
if (!toColumn) return true;
|
if (!toColumn) return true;
|
||||||
|
|
||||||
return fromColumn.dataType === toColumn.dataType;
|
return fromColumn.dataType === toColumn.dataType;
|
||||||
|
|
@ -321,7 +227,7 @@ export const InsertFieldMappingPanel: React.FC<InsertFieldMappingPanelProps> = (
|
||||||
|
|
||||||
if (!selectedFromColumn) return true; // FROM이 선택되지 않았으면 모든 TO 클릭 가능
|
if (!selectedFromColumn) return true; // FROM이 선택되지 않았으면 모든 TO 클릭 가능
|
||||||
|
|
||||||
const fromColumn = actualFromColumns.find((col) => col.columnName === selectedFromColumn);
|
const fromColumn = fromTableColumns.find((col) => col.columnName === selectedFromColumn);
|
||||||
if (!fromColumn) return true;
|
if (!fromColumn) return true;
|
||||||
|
|
||||||
return fromColumn.dataType === toColumn.dataType;
|
return fromColumn.dataType === toColumn.dataType;
|
||||||
|
|
@ -338,8 +244,8 @@ export const InsertFieldMappingPanel: React.FC<InsertFieldMappingPanelProps> = (
|
||||||
<div className="grid grid-cols-2 gap-6">
|
<div className="grid grid-cols-2 gap-6">
|
||||||
<ColumnTableSection
|
<ColumnTableSection
|
||||||
type="from"
|
type="from"
|
||||||
tableName={fromTableDisplayName || fromTableName || "소스 테이블"}
|
tableName={fromTableName || "소스 테이블"}
|
||||||
columns={actualFromColumns}
|
columns={fromTableColumns}
|
||||||
selectedColumn={selectedFromColumn}
|
selectedColumn={selectedFromColumn}
|
||||||
onColumnClick={handleFromColumnClick}
|
onColumnClick={handleFromColumnClick}
|
||||||
searchTerm={fromSearchTerm}
|
searchTerm={fromSearchTerm}
|
||||||
|
|
@ -353,13 +259,13 @@ export const InsertFieldMappingPanel: React.FC<InsertFieldMappingPanelProps> = (
|
||||||
columnMappings={columnMappings}
|
columnMappings={columnMappings}
|
||||||
isColumnClickable={isFromColumnClickable}
|
isColumnClickable={isFromColumnClickable}
|
||||||
oppositeSelectedColumn={selectedToColumn}
|
oppositeSelectedColumn={selectedToColumn}
|
||||||
oppositeColumns={actualToColumns}
|
oppositeColumns={toTableColumns}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ColumnTableSection
|
<ColumnTableSection
|
||||||
type="to"
|
type="to"
|
||||||
tableName={toTableDisplayName || toTableName || "대상 테이블"}
|
tableName={toTableName || "대상 테이블"}
|
||||||
columns={actualToColumns}
|
columns={toTableColumns}
|
||||||
selectedColumn={selectedToColumn}
|
selectedColumn={selectedToColumn}
|
||||||
onColumnClick={handleToColumnClick}
|
onColumnClick={handleToColumnClick}
|
||||||
searchTerm={toSearchTerm}
|
searchTerm={toSearchTerm}
|
||||||
|
|
@ -375,7 +281,7 @@ export const InsertFieldMappingPanel: React.FC<InsertFieldMappingPanelProps> = (
|
||||||
onRemoveMapping={handleRemoveMapping}
|
onRemoveMapping={handleRemoveMapping}
|
||||||
isColumnClickable={isToColumnClickable}
|
isColumnClickable={isToColumnClickable}
|
||||||
oppositeSelectedColumn={selectedFromColumn}
|
oppositeSelectedColumn={selectedFromColumn}
|
||||||
oppositeColumns={actualFromColumns}
|
oppositeColumns={fromTableColumns}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -430,10 +336,10 @@ export const InsertFieldMappingPanel: React.FC<InsertFieldMappingPanelProps> = (
|
||||||
</Button>
|
</Button>
|
||||||
<div className="ml-auto flex gap-2">
|
<div className="ml-auto flex gap-2">
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
FROM: {actualFromColumns.length}
|
FROM: {fromTableColumns.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
TO: {actualToColumns.length}
|
TO: {toTableColumns.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -460,7 +366,7 @@ export const InsertFieldMappingPanel: React.FC<InsertFieldMappingPanelProps> = (
|
||||||
<div className="text-2xl font-bold text-gray-800">
|
<div className="text-2xl font-bold text-gray-800">
|
||||||
{Math.round(
|
{Math.round(
|
||||||
(columnMappings.filter((m) => m.fromColumnName || (m.defaultValue && m.defaultValue.trim())).length /
|
(columnMappings.filter((m) => m.fromColumnName || (m.defaultValue && m.defaultValue.trim())).length /
|
||||||
actualToColumns.length) *
|
toTableColumns.length) *
|
||||||
100,
|
100,
|
||||||
)}
|
)}
|
||||||
%
|
%
|
||||||
|
|
@ -472,7 +378,7 @@ export const InsertFieldMappingPanel: React.FC<InsertFieldMappingPanelProps> = (
|
||||||
<Progress
|
<Progress
|
||||||
value={
|
value={
|
||||||
(columnMappings.filter((m) => m.fromColumnName || (m.defaultValue && m.defaultValue.trim())).length /
|
(columnMappings.filter((m) => m.fromColumnName || (m.defaultValue && m.defaultValue.trim())).length /
|
||||||
actualToColumns.length) *
|
toTableColumns.length) *
|
||||||
100
|
100
|
||||||
}
|
}
|
||||||
className="h-2"
|
className="h-2"
|
||||||
|
|
|
||||||
|
|
@ -83,11 +83,7 @@ export const SimpleKeySettings: React.FC<SimpleKeySettingsProps> = ({
|
||||||
}}
|
}}
|
||||||
className="rounded"
|
className="rounded"
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>{column.displayName || column.columnLabel || column.columnName}</span>
|
||||||
{column.displayName && column.displayName !== column.columnName
|
|
||||||
? column.displayName
|
|
||||||
: column.columnName}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-gray-500">({column.dataType})</span>
|
<span className="text-xs text-gray-500">({column.dataType})</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
|
|
@ -116,11 +112,7 @@ export const SimpleKeySettings: React.FC<SimpleKeySettingsProps> = ({
|
||||||
}}
|
}}
|
||||||
className="rounded"
|
className="rounded"
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>{column.displayName || column.columnLabel || column.columnName}</span>
|
||||||
{column.displayName && column.displayName !== column.columnName
|
|
||||||
? column.displayName
|
|
||||||
: column.columnName}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-gray-500">({column.dataType})</span>
|
<span className="text-xs text-gray-500">({column.dataType})</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,369 +0,0 @@
|
||||||
/**
|
|
||||||
* 테이블 선택 패널
|
|
||||||
* 선택된 커넥션에서 FROM/TO 테이블을 선택할 수 있는 컴포넌트
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { Table, AlertCircle, Info, Search } from "lucide-react";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { getTablesFromConnection, MultiConnectionTableInfo } from "@/lib/api/multiConnection";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
|
|
||||||
export interface TableSelectionPanelProps {
|
|
||||||
fromConnectionId?: number;
|
|
||||||
toConnectionId?: number;
|
|
||||||
selectedFromTable?: string;
|
|
||||||
selectedToTable?: string;
|
|
||||||
onFromTableChange: (tableName: string) => void;
|
|
||||||
onToTableChange: (tableName: string) => void;
|
|
||||||
actionType: "insert" | "update" | "delete";
|
|
||||||
// 🆕 자기 자신 테이블 작업 지원
|
|
||||||
allowSameTable?: boolean;
|
|
||||||
showSameTableWarning?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TableSelectionPanel: React.FC<TableSelectionPanelProps> = ({
|
|
||||||
fromConnectionId,
|
|
||||||
toConnectionId,
|
|
||||||
selectedFromTable,
|
|
||||||
selectedToTable,
|
|
||||||
onFromTableChange,
|
|
||||||
onToTableChange,
|
|
||||||
actionType,
|
|
||||||
allowSameTable = true,
|
|
||||||
showSameTableWarning = true,
|
|
||||||
disabled = false,
|
|
||||||
}) => {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [fromTables, setFromTables] = useState<MultiConnectionTableInfo[]>([]);
|
|
||||||
const [toTables, setToTables] = useState<MultiConnectionTableInfo[]>([]);
|
|
||||||
const [fromLoading, setFromLoading] = useState(false);
|
|
||||||
const [toLoading, setToLoading] = useState(false);
|
|
||||||
const [fromSearchTerm, setFromSearchTerm] = useState("");
|
|
||||||
const [toSearchTerm, setToSearchTerm] = useState("");
|
|
||||||
|
|
||||||
// FROM 커넥션 변경 시 테이블 목록 로딩
|
|
||||||
useEffect(() => {
|
|
||||||
if (fromConnectionId !== undefined && fromConnectionId !== null) {
|
|
||||||
console.log(`🔍 FROM 테이블 로딩 시작: connectionId=${fromConnectionId}`);
|
|
||||||
loadTablesFromConnection(fromConnectionId, setFromTables, setFromLoading, "FROM");
|
|
||||||
} else {
|
|
||||||
setFromTables([]);
|
|
||||||
}
|
|
||||||
}, [fromConnectionId]);
|
|
||||||
|
|
||||||
// TO 커넥션 변경 시 테이블 목록 로딩
|
|
||||||
useEffect(() => {
|
|
||||||
if (toConnectionId !== undefined && toConnectionId !== null) {
|
|
||||||
console.log(`🔍 TO 테이블 로딩 시작: connectionId=${toConnectionId}`);
|
|
||||||
loadTablesFromConnection(toConnectionId, setToTables, setToLoading, "TO");
|
|
||||||
} else {
|
|
||||||
setToTables([]);
|
|
||||||
}
|
|
||||||
}, [toConnectionId]);
|
|
||||||
|
|
||||||
// 테이블 목록 로딩 함수
|
|
||||||
const loadTablesFromConnection = async (
|
|
||||||
connectionId: number,
|
|
||||||
setTables: React.Dispatch<React.SetStateAction<MultiConnectionTableInfo[]>>,
|
|
||||||
setLoading: React.Dispatch<React.SetStateAction<boolean>>,
|
|
||||||
type: "FROM" | "TO",
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
console.log(`🔍 ${type} 테이블 API 호출 시작: connectionId=${connectionId}`);
|
|
||||||
const tables = await getTablesFromConnection(connectionId);
|
|
||||||
console.log(`✅ ${type} 테이블 로딩 성공: ${tables.length}개`);
|
|
||||||
setTables(tables);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`❌ ${type} 테이블 목록 로드 실패:`, error);
|
|
||||||
toast({
|
|
||||||
title: "테이블 로드 실패",
|
|
||||||
description: `${type} 테이블 목록을 불러오는데 실패했습니다. ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
setTables([]); // 에러 시 빈 배열로 설정
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 테이블 필터링
|
|
||||||
const getFilteredTables = (tables: MultiConnectionTableInfo[], searchTerm: string) => {
|
|
||||||
if (!searchTerm.trim()) return tables;
|
|
||||||
|
|
||||||
const term = searchTerm.toLowerCase();
|
|
||||||
return tables.filter(
|
|
||||||
(table) => table.tableName.toLowerCase().includes(term) || table.displayName?.toLowerCase().includes(term),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 액션 타입별 라벨 정의
|
|
||||||
const getTableLabels = () => {
|
|
||||||
switch (actionType) {
|
|
||||||
case "insert":
|
|
||||||
return {
|
|
||||||
from: {
|
|
||||||
title: "소스 테이블",
|
|
||||||
desc: "데이터를 가져올 테이블을 선택하세요",
|
|
||||||
},
|
|
||||||
to: {
|
|
||||||
title: "대상 테이블",
|
|
||||||
desc: "데이터를 저장할 테이블을 선택하세요",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
case "update":
|
|
||||||
return {
|
|
||||||
from: {
|
|
||||||
title: "조건 확인 테이블",
|
|
||||||
desc: "업데이트 조건을 확인할 테이블을 선택하세요",
|
|
||||||
},
|
|
||||||
to: {
|
|
||||||
title: "업데이트 대상 테이블",
|
|
||||||
desc: "데이터를 업데이트할 테이블을 선택하세요",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
case "delete":
|
|
||||||
return {
|
|
||||||
from: {
|
|
||||||
title: "조건 확인 테이블",
|
|
||||||
desc: "삭제 조건을 확인할 테이블을 선택하세요",
|
|
||||||
},
|
|
||||||
to: {
|
|
||||||
title: "삭제 대상 테이블",
|
|
||||||
desc: "데이터를 삭제할 테이블을 선택하세요",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 자기 자신 테이블 작업 경고
|
|
||||||
const getSameTableWarning = () => {
|
|
||||||
if (
|
|
||||||
showSameTableWarning &&
|
|
||||||
fromConnectionId === toConnectionId &&
|
|
||||||
selectedFromTable === selectedToTable &&
|
|
||||||
selectedFromTable
|
|
||||||
) {
|
|
||||||
switch (actionType) {
|
|
||||||
case "update":
|
|
||||||
return "⚠️ 같은 테이블에서 UPDATE 작업을 수행합니다. 무한 루프에 주의하세요.";
|
|
||||||
case "delete":
|
|
||||||
return "🚨 같은 테이블에서 DELETE 작업을 수행합니다. 매우 위험할 수 있습니다.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const labels = getTableLabels();
|
|
||||||
const warningMessage = getSameTableWarning();
|
|
||||||
const filteredFromTables = getFilteredTables(fromTables, fromSearchTerm);
|
|
||||||
const filteredToTables = getFilteredTables(toTables, toSearchTerm);
|
|
||||||
|
|
||||||
// 테이블 아이템 렌더링
|
|
||||||
const renderTableItem = (table: MultiConnectionTableInfo) => (
|
|
||||||
<SelectItem key={table.tableName} value={table.tableName}>
|
|
||||||
<div className="flex w-full items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Table className="h-3 w-3" />
|
|
||||||
<span className="font-medium">
|
|
||||||
{table.displayName && table.displayName !== table.tableName ? table.displayName : table.tableName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{table.columnCount}개 컬럼
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 로딩 상태 렌더링
|
|
||||||
const renderLoadingState = (title: string) => (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<div className="h-4 w-4 animate-pulse rounded bg-gray-300" />
|
|
||||||
{title}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="h-9 w-full animate-pulse rounded bg-gray-200" />
|
|
||||||
<div className="h-10 w-full animate-pulse rounded bg-gray-200" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-6">
|
|
||||||
{/* FROM 테이블 선택 */}
|
|
||||||
{fromLoading ? (
|
|
||||||
renderLoadingState(labels.from.title)
|
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Table className="h-4 w-4" />
|
|
||||||
{labels.from.title}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>{labels.from.desc}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{/* 검색 필드 */}
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
|
||||||
<Input
|
|
||||||
placeholder="테이블 검색..."
|
|
||||||
value={fromSearchTerm}
|
|
||||||
onChange={(e) => setFromSearchTerm(e.target.value)}
|
|
||||||
className="pl-9"
|
|
||||||
disabled={disabled || fromConnectionId === undefined || fromConnectionId === null}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 테이블 선택 */}
|
|
||||||
<Select
|
|
||||||
value={selectedFromTable || ""}
|
|
||||||
onValueChange={onFromTableChange}
|
|
||||||
disabled={disabled || fromConnectionId === undefined || fromConnectionId === null}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="테이블을 선택하세요" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{filteredFromTables.length > 0 ? (
|
|
||||||
filteredFromTables.map(renderTableItem)
|
|
||||||
) : (
|
|
||||||
<div className="text-muted-foreground p-2 text-center text-sm">
|
|
||||||
{fromSearchTerm ? "검색 결과가 없습니다" : "테이블이 없습니다"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{/* 테이블 정보 */}
|
|
||||||
{selectedFromTable && fromTables.find((t) => t.tableName === selectedFromTable) && (
|
|
||||||
<div className="text-muted-foreground text-xs">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Info className="h-3 w-3" />
|
|
||||||
<span>
|
|
||||||
{fromTables.find((t) => t.tableName === selectedFromTable)?.columnCount}개 컬럼, 커넥션:{" "}
|
|
||||||
{fromTables.find((t) => t.tableName === selectedFromTable)?.connectionName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* TO 테이블 선택 */}
|
|
||||||
{toLoading ? (
|
|
||||||
renderLoadingState(labels.to.title)
|
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Table className="h-4 w-4" />
|
|
||||||
{labels.to.title}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>{labels.to.desc}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{/* 검색 필드 */}
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
|
||||||
<Input
|
|
||||||
placeholder="테이블 검색..."
|
|
||||||
value={toSearchTerm}
|
|
||||||
onChange={(e) => setToSearchTerm(e.target.value)}
|
|
||||||
className="pl-9"
|
|
||||||
disabled={disabled || toConnectionId === undefined || toConnectionId === null}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 테이블 선택 */}
|
|
||||||
<Select
|
|
||||||
value={selectedToTable || ""}
|
|
||||||
onValueChange={onToTableChange}
|
|
||||||
disabled={disabled || toConnectionId === undefined || toConnectionId === null}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="테이블을 선택하세요" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{filteredToTables.length > 0 ? (
|
|
||||||
filteredToTables.map(renderTableItem)
|
|
||||||
) : (
|
|
||||||
<div className="text-muted-foreground p-2 text-center text-sm">
|
|
||||||
{toSearchTerm ? "검색 결과가 없습니다" : "테이블이 없습니다"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{/* 테이블 정보 */}
|
|
||||||
{selectedToTable && toTables.find((t) => t.tableName === selectedToTable) && (
|
|
||||||
<div className="text-muted-foreground text-xs">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Info className="h-3 w-3" />
|
|
||||||
<span>
|
|
||||||
{toTables.find((t) => t.tableName === selectedToTable)?.columnCount}개 컬럼, 커넥션:{" "}
|
|
||||||
{toTables.find((t) => t.tableName === selectedToTable)?.connectionName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 🆕 자기 자신 테이블 작업 시 경고 */}
|
|
||||||
{warningMessage && (
|
|
||||||
<Alert variant={actionType === "delete" ? "destructive" : "default"}>
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>{warningMessage}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 선택 상태 표시 */}
|
|
||||||
{selectedFromTable && selectedToTable && (
|
|
||||||
<div className="text-muted-foreground text-sm">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>테이블 매핑:</span>
|
|
||||||
<Badge variant="secondary" className="font-mono">
|
|
||||||
{(() => {
|
|
||||||
const fromTable = fromTables.find((t) => t.tableName === selectedFromTable);
|
|
||||||
return fromTable?.displayName && fromTable.displayName !== fromTable.tableName
|
|
||||||
? fromTable.displayName
|
|
||||||
: selectedFromTable;
|
|
||||||
})()}
|
|
||||||
</Badge>
|
|
||||||
<span>→</span>
|
|
||||||
<Badge variant="secondary" className="font-mono">
|
|
||||||
{(() => {
|
|
||||||
const toTable = toTables.find((t) => t.tableName === selectedToTable);
|
|
||||||
return toTable?.displayName && toTable.displayName !== toTable.tableName
|
|
||||||
? toTable.displayName
|
|
||||||
: selectedToTable;
|
|
||||||
})()}
|
|
||||||
</Badge>
|
|
||||||
{selectedFromTable === selectedToTable && (
|
|
||||||
<Badge variant="outline" className="text-orange-600">
|
|
||||||
자기 자신
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,561 +0,0 @@
|
||||||
/**
|
|
||||||
* UPDATE 필드 매핑 패널
|
|
||||||
* UPDATE 액션용 조건 설정 및 필드 매핑 컴포넌트
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { Plus, X, Search, AlertCircle, ArrowRight } from "lucide-react";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { ColumnInfo, getColumnsFromConnection } from "@/lib/api/multiConnection";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
|
|
||||||
export interface UpdateCondition {
|
|
||||||
id: string;
|
|
||||||
fromColumn: string;
|
|
||||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN" | "NOT IN";
|
|
||||||
value: string | string[];
|
|
||||||
logicalOperator?: "AND" | "OR";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateFieldMapping {
|
|
||||||
id: string;
|
|
||||||
fromColumn: string;
|
|
||||||
toColumn: string;
|
|
||||||
transformFunction?: string;
|
|
||||||
defaultValue?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WhereCondition {
|
|
||||||
id: string;
|
|
||||||
toColumn: string;
|
|
||||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN" | "NOT IN";
|
|
||||||
valueSource: "from_column" | "static" | "current_timestamp";
|
|
||||||
fromColumn?: string;
|
|
||||||
staticValue?: string;
|
|
||||||
logicalOperator?: "AND" | "OR";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateFieldMappingPanelProps {
|
|
||||||
action: any;
|
|
||||||
actionIndex: number;
|
|
||||||
settings: any;
|
|
||||||
onSettingsChange: (settings: any) => void;
|
|
||||||
fromConnectionId?: number;
|
|
||||||
toConnectionId?: number;
|
|
||||||
fromTableName?: string;
|
|
||||||
toTableName?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const UpdateFieldMappingPanel: React.FC<UpdateFieldMappingPanelProps> = ({
|
|
||||||
action,
|
|
||||||
actionIndex,
|
|
||||||
settings,
|
|
||||||
onSettingsChange,
|
|
||||||
fromConnectionId,
|
|
||||||
toConnectionId,
|
|
||||||
fromTableName,
|
|
||||||
toTableName,
|
|
||||||
disabled = false,
|
|
||||||
}) => {
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
// 상태 관리
|
|
||||||
const [fromTableColumns, setFromTableColumns] = useState<ColumnInfo[]>([]);
|
|
||||||
const [toTableColumns, setToTableColumns] = useState<ColumnInfo[]>([]);
|
|
||||||
const [updateConditions, setUpdateConditions] = useState<UpdateCondition[]>([]);
|
|
||||||
const [updateFields, setUpdateFields] = useState<UpdateFieldMapping[]>([]);
|
|
||||||
const [whereConditions, setWhereConditions] = useState<WhereCondition[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
// 검색 상태
|
|
||||||
const [fromColumnSearch, setFromColumnSearch] = useState("");
|
|
||||||
const [toColumnSearch, setToColumnSearch] = useState("");
|
|
||||||
|
|
||||||
// 컬럼 정보 로드
|
|
||||||
useEffect(() => {
|
|
||||||
if (fromConnectionId !== undefined && fromTableName) {
|
|
||||||
loadColumnInfo(fromConnectionId, fromTableName, setFromTableColumns, "FROM");
|
|
||||||
}
|
|
||||||
}, [fromConnectionId, fromTableName]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (toConnectionId !== undefined && toTableName) {
|
|
||||||
loadColumnInfo(toConnectionId, toTableName, setToTableColumns, "TO");
|
|
||||||
}
|
|
||||||
}, [toConnectionId, toTableName]);
|
|
||||||
|
|
||||||
// 컬럼 정보 로드 함수
|
|
||||||
const loadColumnInfo = async (
|
|
||||||
connectionId: number,
|
|
||||||
tableName: string,
|
|
||||||
setColumns: React.Dispatch<React.SetStateAction<ColumnInfo[]>>,
|
|
||||||
type: "FROM" | "TO",
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const columns = await getColumnsFromConnection(connectionId, tableName);
|
|
||||||
setColumns(columns);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`${type} 컬럼 정보 로드 실패:`, error);
|
|
||||||
toast({
|
|
||||||
title: "컬럼 로드 실패",
|
|
||||||
description: `${type} 테이블의 컬럼 정보를 불러오는데 실패했습니다.`,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 컬럼 필터링
|
|
||||||
const getFilteredColumns = (columns: ColumnInfo[], searchTerm: string) => {
|
|
||||||
if (!searchTerm.trim()) return columns;
|
|
||||||
|
|
||||||
const term = searchTerm.toLowerCase();
|
|
||||||
return columns.filter(
|
|
||||||
(col) => col.columnName.toLowerCase().includes(term) || col.displayName.toLowerCase().includes(term),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// UPDATE 조건 추가
|
|
||||||
const addUpdateCondition = () => {
|
|
||||||
const newCondition: UpdateCondition = {
|
|
||||||
id: `condition_${Date.now()}`,
|
|
||||||
fromColumn: "",
|
|
||||||
operator: "=",
|
|
||||||
value: "",
|
|
||||||
logicalOperator: updateConditions.length > 0 ? "AND" : undefined,
|
|
||||||
};
|
|
||||||
setUpdateConditions([...updateConditions, newCondition]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// UPDATE 조건 제거
|
|
||||||
const removeUpdateCondition = (id: string) => {
|
|
||||||
setUpdateConditions(updateConditions.filter((c) => c.id !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
// UPDATE 조건 수정
|
|
||||||
const updateCondition = (id: string, field: keyof UpdateCondition, value: any) => {
|
|
||||||
setUpdateConditions(updateConditions.map((c) => (c.id === id ? { ...c, [field]: value } : c)));
|
|
||||||
};
|
|
||||||
|
|
||||||
// UPDATE 필드 매핑 추가
|
|
||||||
const addUpdateFieldMapping = () => {
|
|
||||||
const newMapping: UpdateFieldMapping = {
|
|
||||||
id: `mapping_${Date.now()}`,
|
|
||||||
fromColumn: "",
|
|
||||||
toColumn: "",
|
|
||||||
transformFunction: "",
|
|
||||||
defaultValue: "",
|
|
||||||
};
|
|
||||||
setUpdateFields([...updateFields, newMapping]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// UPDATE 필드 매핑 제거
|
|
||||||
const removeUpdateFieldMapping = (id: string) => {
|
|
||||||
setUpdateFields(updateFields.filter((m) => m.id !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
// UPDATE 필드 매핑 수정
|
|
||||||
const updateFieldMapping = (id: string, field: keyof UpdateFieldMapping, value: any) => {
|
|
||||||
setUpdateFields(updateFields.map((m) => (m.id === id ? { ...m, [field]: value } : m)));
|
|
||||||
};
|
|
||||||
|
|
||||||
// WHERE 조건 추가
|
|
||||||
const addWhereCondition = () => {
|
|
||||||
const newCondition: WhereCondition = {
|
|
||||||
id: `where_${Date.now()}`,
|
|
||||||
toColumn: "",
|
|
||||||
operator: "=",
|
|
||||||
valueSource: "from_column",
|
|
||||||
fromColumn: "",
|
|
||||||
staticValue: "",
|
|
||||||
logicalOperator: whereConditions.length > 0 ? "AND" : undefined,
|
|
||||||
};
|
|
||||||
setWhereConditions([...whereConditions, newCondition]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// WHERE 조건 제거
|
|
||||||
const removeWhereCondition = (id: string) => {
|
|
||||||
setWhereConditions(whereConditions.filter((c) => c.id !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
// WHERE 조건 수정
|
|
||||||
const updateWhereCondition = (id: string, field: keyof WhereCondition, value: any) => {
|
|
||||||
setWhereConditions(whereConditions.map((c) => (c.id === id ? { ...c, [field]: value } : c)));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 자기 자신 테이블 작업 경고
|
|
||||||
const getSelfTableWarning = () => {
|
|
||||||
if (fromConnectionId === toConnectionId && fromTableName === toTableName) {
|
|
||||||
return "⚠️ 자기 자신 테이블 UPDATE 작업입니다. 무한 루프 및 데이터 손상에 주의하세요.";
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const warningMessage = getSelfTableWarning();
|
|
||||||
const filteredFromColumns = getFilteredColumns(fromTableColumns, fromColumnSearch);
|
|
||||||
const filteredToColumns = getFilteredColumns(toTableColumns, toColumnSearch);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* 경고 메시지 */}
|
|
||||||
{warningMessage && (
|
|
||||||
<Alert variant="default">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>{warningMessage}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* UPDATE 조건 설정 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">🔍 업데이트 조건 설정</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
FROM 테이블에서 어떤 조건을 만족하는 데이터가 있을 때 TO 테이블을 업데이트할지 설정하세요
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{/* 검색 필드 */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium">FROM 컬럼 검색</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
|
||||||
<Input
|
|
||||||
placeholder="컬럼 검색..."
|
|
||||||
value={fromColumnSearch}
|
|
||||||
onChange={(e) => setFromColumnSearch(e.target.value)}
|
|
||||||
className="pl-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 업데이트 조건 리스트 */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
{updateConditions.map((condition, index) => (
|
|
||||||
<div key={condition.id} className="flex items-center gap-2 rounded-lg border p-3">
|
|
||||||
{index > 0 && (
|
|
||||||
<Select
|
|
||||||
value={condition.logicalOperator || "AND"}
|
|
||||||
onValueChange={(value) => updateCondition(condition.id, "logicalOperator", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-20">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="AND">AND</SelectItem>
|
|
||||||
<SelectItem value="OR">OR</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={condition.fromColumn}
|
|
||||||
onValueChange={(value) => updateCondition(condition.id, "fromColumn", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-48">
|
|
||||||
<SelectValue placeholder="FROM 컬럼" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{filteredFromColumns.map((col) => (
|
|
||||||
<SelectItem key={col.columnName} value={col.columnName}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>{col.columnName}</span>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{col.dataType}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={condition.operator}
|
|
||||||
onValueChange={(value) => updateCondition(condition.id, "operator", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-20">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="=">=</SelectItem>
|
|
||||||
<SelectItem value="!=">!=</SelectItem>
|
|
||||||
<SelectItem value=">">></SelectItem>
|
|
||||||
<SelectItem value="<"><</SelectItem>
|
|
||||||
<SelectItem value=">=">>=</SelectItem>
|
|
||||||
<SelectItem value="<="><=</SelectItem>
|
|
||||||
<SelectItem value="LIKE">LIKE</SelectItem>
|
|
||||||
<SelectItem value="IN">IN</SelectItem>
|
|
||||||
<SelectItem value="NOT IN">NOT IN</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder="값"
|
|
||||||
value={condition.value as string}
|
|
||||||
onChange={(e) => updateCondition(condition.id, "value", e.target.value)}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button variant="outline" size="sm" onClick={() => removeUpdateCondition(condition.id)}>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Button variant="outline" onClick={addUpdateCondition} className="w-full" disabled={disabled}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
업데이트 조건 추가
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* UPDATE 필드 매핑 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">📝 업데이트 필드 매핑</CardTitle>
|
|
||||||
<CardDescription>FROM 테이블의 값을 TO 테이블의 어떤 필드에 업데이트할지 설정하세요</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{/* 검색 필드 */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium">FROM 컬럼 검색</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
|
||||||
<Input
|
|
||||||
placeholder="컬럼 검색..."
|
|
||||||
value={fromColumnSearch}
|
|
||||||
onChange={(e) => setFromColumnSearch(e.target.value)}
|
|
||||||
className="pl-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium">TO 컬럼 검색</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
|
||||||
<Input
|
|
||||||
placeholder="컬럼 검색..."
|
|
||||||
value={toColumnSearch}
|
|
||||||
onChange={(e) => setToColumnSearch(e.target.value)}
|
|
||||||
className="pl-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 필드 매핑 리스트 */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
{updateFields.map((mapping) => (
|
|
||||||
<div key={mapping.id} className="flex items-center gap-2 rounded-lg border p-3">
|
|
||||||
<Select
|
|
||||||
value={mapping.fromColumn}
|
|
||||||
onValueChange={(value) => updateFieldMapping(mapping.id, "fromColumn", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-48">
|
|
||||||
<SelectValue placeholder="FROM 컬럼" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{filteredFromColumns.map((col) => (
|
|
||||||
<SelectItem key={col.columnName} value={col.columnName}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>{col.columnName}</span>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{col.dataType}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<ArrowRight className="text-muted-foreground h-4 w-4" />
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={mapping.toColumn}
|
|
||||||
onValueChange={(value) => updateFieldMapping(mapping.id, "toColumn", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-48">
|
|
||||||
<SelectValue placeholder="TO 컬럼" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{filteredToColumns.map((col) => (
|
|
||||||
<SelectItem key={col.columnName} value={col.columnName}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>{col.columnName}</span>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{col.dataType}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder="기본값 (선택사항)"
|
|
||||||
value={mapping.defaultValue || ""}
|
|
||||||
onChange={(e) => updateFieldMapping(mapping.id, "defaultValue", e.target.value)}
|
|
||||||
className="w-32"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button variant="outline" size="sm" onClick={() => removeUpdateFieldMapping(mapping.id)}>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Button variant="outline" onClick={addUpdateFieldMapping} className="w-full" disabled={disabled}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
필드 매핑 추가
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* WHERE 조건 설정 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">🎯 업데이트 대상 조건</CardTitle>
|
|
||||||
<CardDescription>TO 테이블에서 어떤 레코드를 업데이트할지 WHERE 조건을 설정하세요</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{/* WHERE 조건 리스트 */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
{whereConditions.map((condition, index) => (
|
|
||||||
<div key={condition.id} className="flex items-center gap-2 rounded-lg border p-3">
|
|
||||||
{index > 0 && (
|
|
||||||
<Select
|
|
||||||
value={condition.logicalOperator || "AND"}
|
|
||||||
onValueChange={(value) => updateWhereCondition(condition.id, "logicalOperator", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-20">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="AND">AND</SelectItem>
|
|
||||||
<SelectItem value="OR">OR</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={condition.toColumn}
|
|
||||||
onValueChange={(value) => updateWhereCondition(condition.id, "toColumn", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-48">
|
|
||||||
<SelectValue placeholder="TO 컬럼" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{filteredToColumns.map((col) => (
|
|
||||||
<SelectItem key={col.columnName} value={col.columnName}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>{col.columnName}</span>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{col.dataType}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={condition.operator}
|
|
||||||
onValueChange={(value) => updateWhereCondition(condition.id, "operator", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-20">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="=">=</SelectItem>
|
|
||||||
<SelectItem value="!=">!=</SelectItem>
|
|
||||||
<SelectItem value=">">></SelectItem>
|
|
||||||
<SelectItem value="<"><</SelectItem>
|
|
||||||
<SelectItem value=">=">>=</SelectItem>
|
|
||||||
<SelectItem value="<="><=</SelectItem>
|
|
||||||
<SelectItem value="LIKE">LIKE</SelectItem>
|
|
||||||
<SelectItem value="IN">IN</SelectItem>
|
|
||||||
<SelectItem value="NOT IN">NOT IN</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={condition.valueSource}
|
|
||||||
onValueChange={(value) => updateWhereCondition(condition.id, "valueSource", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-32">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="from_column">FROM 컬럼</SelectItem>
|
|
||||||
<SelectItem value="static">고정값</SelectItem>
|
|
||||||
<SelectItem value="current_timestamp">현재 시간</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{condition.valueSource === "from_column" && (
|
|
||||||
<Select
|
|
||||||
value={condition.fromColumn || ""}
|
|
||||||
onValueChange={(value) => updateWhereCondition(condition.id, "fromColumn", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-48">
|
|
||||||
<SelectValue placeholder="FROM 컬럼 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{filteredFromColumns.map((col) => (
|
|
||||||
<SelectItem key={col.columnName} value={col.columnName}>
|
|
||||||
{col.columnName}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{condition.valueSource === "static" && (
|
|
||||||
<Input
|
|
||||||
placeholder="고정값"
|
|
||||||
value={condition.staticValue || ""}
|
|
||||||
onChange={(e) => updateWhereCondition(condition.id, "staticValue", e.target.value)}
|
|
||||||
className="w-32"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button variant="outline" size="sm" onClick={() => removeWhereCondition(condition.id)}>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Button variant="outline" onClick={addWhereCondition} className="w-full" disabled={disabled}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
WHERE 조건 추가
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{whereConditions.length === 0 && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>안전을 위해 UPDATE 작업에는 최소 하나 이상의 WHERE 조건이 필요합니다.</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -51,7 +51,7 @@ const TokenManager = {
|
||||||
// Axios 인스턴스 생성
|
// Axios 인스턴스 생성
|
||||||
export const apiClient = axios.create({
|
export const apiClient = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
timeout: 30000, // 30초로 증가 (다중 커넥션 처리 시간 고려)
|
timeout: 10000,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,154 +0,0 @@
|
||||||
/**
|
|
||||||
* 다중 커넥션 관리 API 클라이언트
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { apiClient } from "./client";
|
|
||||||
|
|
||||||
export interface MultiConnectionTableInfo {
|
|
||||||
tableName: string;
|
|
||||||
displayName?: string;
|
|
||||||
columnCount: number;
|
|
||||||
connectionId: number;
|
|
||||||
connectionName: string;
|
|
||||||
dbType: 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 ConnectionInfo {
|
|
||||||
id: number;
|
|
||||||
connection_name: string;
|
|
||||||
description?: string;
|
|
||||||
db_type: string;
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
database_name: string;
|
|
||||||
username: string;
|
|
||||||
is_active: string;
|
|
||||||
company_code: string;
|
|
||||||
created_date: Date;
|
|
||||||
updated_date: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ValidationResult {
|
|
||||||
isValid: boolean;
|
|
||||||
error?: string;
|
|
||||||
warnings?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 제어관리용 활성 커넥션 목록 조회 (메인 DB 포함)
|
|
||||||
*/
|
|
||||||
export const getActiveConnections = async (): Promise<ConnectionInfo[]> => {
|
|
||||||
const response = await apiClient.get("/external-db-connections/control/active");
|
|
||||||
return response.data.data || [];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 특정 커넥션의 테이블 목록 조회
|
|
||||||
*/
|
|
||||||
export const getTablesFromConnection = async (connectionId: number): Promise<MultiConnectionTableInfo[]> => {
|
|
||||||
const response = await apiClient.get(`/multi-connection/connections/${connectionId}/tables`);
|
|
||||||
return response.data.data || [];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 특정 커넥션의 테이블 컬럼 정보 조회
|
|
||||||
*/
|
|
||||||
export const getColumnsFromConnection = async (connectionId: number, tableName: string): Promise<ColumnInfo[]> => {
|
|
||||||
const response = await apiClient.get(`/multi-connection/connections/${connectionId}/tables/${tableName}/columns`);
|
|
||||||
return response.data.data || [];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 특정 커넥션에서 데이터 조회
|
|
||||||
*/
|
|
||||||
export const queryDataFromConnection = async (
|
|
||||||
connectionId: number,
|
|
||||||
tableName: string,
|
|
||||||
conditions?: Record<string, any>,
|
|
||||||
): Promise<Record<string, any>[]> => {
|
|
||||||
const response = await apiClient.post(`/multi-connection/connections/${connectionId}/query`, {
|
|
||||||
tableName,
|
|
||||||
conditions,
|
|
||||||
});
|
|
||||||
return response.data.data || [];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 특정 커넥션에 데이터 삽입
|
|
||||||
*/
|
|
||||||
export const insertDataToConnection = async (
|
|
||||||
connectionId: number,
|
|
||||||
tableName: string,
|
|
||||||
data: Record<string, any>,
|
|
||||||
): Promise<any> => {
|
|
||||||
const response = await apiClient.post(`/multi-connection/connections/${connectionId}/insert`, {
|
|
||||||
tableName,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
return response.data.data || {};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 특정 커넥션의 데이터 업데이트
|
|
||||||
*/
|
|
||||||
export const updateDataInConnection = async (
|
|
||||||
connectionId: number,
|
|
||||||
tableName: string,
|
|
||||||
data: Record<string, any>,
|
|
||||||
conditions: Record<string, any>,
|
|
||||||
): Promise<any> => {
|
|
||||||
const response = await apiClient.put(`/multi-connection/connections/${connectionId}/update`, {
|
|
||||||
tableName,
|
|
||||||
data,
|
|
||||||
conditions,
|
|
||||||
});
|
|
||||||
return response.data.data || {};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 특정 커넥션에서 데이터 삭제
|
|
||||||
*/
|
|
||||||
export const deleteDataFromConnection = async (
|
|
||||||
connectionId: number,
|
|
||||||
tableName: string,
|
|
||||||
conditions: Record<string, any>,
|
|
||||||
maxDeleteCount?: number,
|
|
||||||
): Promise<any> => {
|
|
||||||
const response = await apiClient.delete(`/multi-connection/connections/${connectionId}/delete`, {
|
|
||||||
data: {
|
|
||||||
tableName,
|
|
||||||
conditions,
|
|
||||||
maxDeleteCount,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return response.data.data || {};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 자기 자신 테이블 작업 검증
|
|
||||||
*/
|
|
||||||
export const validateSelfTableOperation = async (
|
|
||||||
tableName: string,
|
|
||||||
operation: "update" | "delete",
|
|
||||||
conditions: any[],
|
|
||||||
): Promise<ValidationResult> => {
|
|
||||||
const response = await apiClient.post("/multi-connection/validate-self-operation", {
|
|
||||||
tableName,
|
|
||||||
operation,
|
|
||||||
conditions,
|
|
||||||
});
|
|
||||||
return response.data.data || {};
|
|
||||||
};
|
|
||||||
|
|
@ -1,406 +0,0 @@
|
||||||
/**
|
|
||||||
* 매핑 제약사항 검증 유틸리티
|
|
||||||
* INSERT/UPDATE/DELETE 액션의 매핑 규칙을 검증합니다.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface ValidationResult {
|
|
||||||
isValid: boolean;
|
|
||||||
error?: string;
|
|
||||||
warnings?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ColumnMapping {
|
|
||||||
id?: string;
|
|
||||||
fromColumnName?: string;
|
|
||||||
toColumnName: string;
|
|
||||||
sourceTable?: string;
|
|
||||||
targetTable?: string;
|
|
||||||
defaultValue?: string;
|
|
||||||
transformFunction?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateCondition {
|
|
||||||
id: string;
|
|
||||||
fromColumn: string;
|
|
||||||
operator: string;
|
|
||||||
value: string | string[];
|
|
||||||
logicalOperator?: "AND" | "OR";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WhereCondition {
|
|
||||||
id: string;
|
|
||||||
toColumn: string;
|
|
||||||
operator: string;
|
|
||||||
valueSource: string;
|
|
||||||
fromColumn?: string;
|
|
||||||
staticValue?: string;
|
|
||||||
logicalOperator?: "AND" | "OR";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeleteCondition {
|
|
||||||
id: string;
|
|
||||||
fromColumn: string;
|
|
||||||
operator: string;
|
|
||||||
value: string | string[];
|
|
||||||
logicalOperator?: "AND" | "OR";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 전체 매핑 제약사항 검증
|
|
||||||
*/
|
|
||||||
export const validateMappingConstraints = (
|
|
||||||
actionType: "insert" | "update" | "delete",
|
|
||||||
newMapping: ColumnMapping,
|
|
||||||
existingMappings: ColumnMapping[],
|
|
||||||
): ValidationResult => {
|
|
||||||
switch (actionType) {
|
|
||||||
case "insert":
|
|
||||||
return validateInsertMapping(newMapping, existingMappings);
|
|
||||||
case "update":
|
|
||||||
return validateUpdateMapping(newMapping, existingMappings);
|
|
||||||
case "delete":
|
|
||||||
return validateDeleteConditions(newMapping, existingMappings);
|
|
||||||
default:
|
|
||||||
return { isValid: false, error: "지원하지 않는 액션 타입입니다." };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* INSERT 매핑 검증
|
|
||||||
* 규칙: 1:N 매핑 허용, N:1 매핑 금지
|
|
||||||
*/
|
|
||||||
export const validateInsertMapping = (
|
|
||||||
newMapping: ColumnMapping,
|
|
||||||
existingMappings: ColumnMapping[],
|
|
||||||
): ValidationResult => {
|
|
||||||
// TO 컬럼이 이미 다른 FROM 컬럼과 매핑되어 있는지 확인
|
|
||||||
const existingToMapping = existingMappings.find((mapping) => mapping.toColumnName === newMapping.toColumnName);
|
|
||||||
|
|
||||||
if (
|
|
||||||
existingToMapping &&
|
|
||||||
existingToMapping.fromColumnName &&
|
|
||||||
existingToMapping.fromColumnName !== newMapping.fromColumnName
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
isValid: false,
|
|
||||||
error: `대상 컬럼 '${newMapping.toColumnName}'은 이미 '${existingToMapping.fromColumnName}'과 매핑되어 있습니다.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 기본값이 설정된 경우와 FROM 컬럼이 동시에 설정되어 있는지 확인
|
|
||||||
if (newMapping.fromColumnName && newMapping.defaultValue && newMapping.defaultValue.trim()) {
|
|
||||||
return {
|
|
||||||
isValid: false,
|
|
||||||
error: `'${newMapping.toColumnName}' 컬럼에는 FROM 컬럼과 기본값을 동시에 설정할 수 없습니다.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { isValid: true };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UPDATE 매핑 검증
|
|
||||||
*/
|
|
||||||
export const validateUpdateMapping = (
|
|
||||||
newMapping: ColumnMapping,
|
|
||||||
existingMappings: ColumnMapping[],
|
|
||||||
): ValidationResult => {
|
|
||||||
// 기본 INSERT 규칙 적용
|
|
||||||
const baseValidation = validateInsertMapping(newMapping, existingMappings);
|
|
||||||
if (!baseValidation.isValid) {
|
|
||||||
return baseValidation;
|
|
||||||
}
|
|
||||||
|
|
||||||
// UPDATE 특화 검증 로직 추가 가능
|
|
||||||
return { isValid: true };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE 조건 검증
|
|
||||||
*/
|
|
||||||
export const validateDeleteConditions = (
|
|
||||||
newMapping: ColumnMapping,
|
|
||||||
existingMappings: ColumnMapping[],
|
|
||||||
): ValidationResult => {
|
|
||||||
// DELETE는 기본적으로 조건 기반이므로 매핑 제약이 다름
|
|
||||||
return { isValid: true };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 자기 자신 테이블 UPDATE 작업 검증
|
|
||||||
*/
|
|
||||||
export const validateSelfTableUpdate = (
|
|
||||||
fromTable: string,
|
|
||||||
toTable: string,
|
|
||||||
updateConditions: UpdateCondition[],
|
|
||||||
whereConditions: WhereCondition[],
|
|
||||||
): ValidationResult => {
|
|
||||||
if (fromTable === toTable) {
|
|
||||||
// 1. WHERE 조건 필수
|
|
||||||
if (!whereConditions.length) {
|
|
||||||
return {
|
|
||||||
isValid: false,
|
|
||||||
error: "자기 자신 테이블 업데이트 시 WHERE 조건이 필수입니다.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 업데이트 조건과 WHERE 조건이 겹치지 않도록 체크
|
|
||||||
const conditionColumns = updateConditions.map((c) => c.fromColumn);
|
|
||||||
const whereColumns = whereConditions.map((c) => c.toColumn);
|
|
||||||
const overlap = conditionColumns.filter((col) => whereColumns.includes(col));
|
|
||||||
|
|
||||||
if (overlap.length > 0) {
|
|
||||||
return {
|
|
||||||
isValid: false,
|
|
||||||
error: `업데이트 조건과 WHERE 조건에서 같은 컬럼(${overlap.join(", ")})을 사용하면 예상치 못한 결과가 발생할 수 있습니다.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 무한 루프 방지 체크
|
|
||||||
const hasInfiniteLoopRisk = updateConditions.some((condition) =>
|
|
||||||
whereConditions.some(
|
|
||||||
(where) => where.fromColumn === condition.toColumn && where.toColumn === condition.fromColumn,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasInfiniteLoopRisk) {
|
|
||||||
return {
|
|
||||||
isValid: false,
|
|
||||||
error: "자기 참조 업데이트로 인한 무한 루프 위험이 있습니다.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { isValid: true };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 자기 자신 테이블 DELETE 작업 검증
|
|
||||||
*/
|
|
||||||
export const validateSelfTableDelete = (
|
|
||||||
fromTable: string,
|
|
||||||
toTable: string,
|
|
||||||
deleteConditions: DeleteCondition[],
|
|
||||||
whereConditions: WhereCondition[],
|
|
||||||
maxDeleteCount: number,
|
|
||||||
): ValidationResult => {
|
|
||||||
if (fromTable === toTable) {
|
|
||||||
// 1. WHERE 조건 필수 체크
|
|
||||||
if (!whereConditions.length) {
|
|
||||||
return {
|
|
||||||
isValid: false,
|
|
||||||
error: "자기 자신 테이블 삭제 시 WHERE 조건이 필수입니다.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 강화된 안전장치: 더 엄격한 제한
|
|
||||||
const selfDeleteMaxCount = Math.min(maxDeleteCount, 10);
|
|
||||||
|
|
||||||
if (maxDeleteCount > selfDeleteMaxCount) {
|
|
||||||
return {
|
|
||||||
isValid: false,
|
|
||||||
error: `자기 자신 테이블 삭제 시 최대 ${selfDeleteMaxCount}개까지만 허용됩니다.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 삭제 조건이 너무 광범위한지 체크
|
|
||||||
const hasBroadCondition = deleteConditions.some(
|
|
||||||
(condition) =>
|
|
||||||
condition.operator === "!=" || condition.operator === "NOT IN" || condition.operator === "NOT EXISTS",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasBroadCondition) {
|
|
||||||
return {
|
|
||||||
isValid: false,
|
|
||||||
error: "자기 자신 테이블 삭제 시 부정 조건(!=, NOT IN, NOT EXISTS)은 위험합니다.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. WHERE 조건이 충분히 구체적인지 체크
|
|
||||||
if (whereConditions.length < 2) {
|
|
||||||
return {
|
|
||||||
isValid: false,
|
|
||||||
error: "자기 자신 테이블 삭제 시 WHERE 조건을 2개 이상 설정하는 것을 권장합니다.",
|
|
||||||
warnings: ["안전을 위해 더 구체적인 조건을 설정하세요."],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { isValid: true };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 컬럼 데이터 타입 호환성 검증
|
|
||||||
*/
|
|
||||||
export const validateDataTypeCompatibility = (fromColumnType: string, toColumnType: string): ValidationResult => {
|
|
||||||
// 기본 호환성 규칙
|
|
||||||
const fromType = normalizeDataType(fromColumnType);
|
|
||||||
const toType = normalizeDataType(toColumnType);
|
|
||||||
|
|
||||||
// 같은 타입이면 호환
|
|
||||||
if (fromType === toType) {
|
|
||||||
return { isValid: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 숫자 타입 간 호환성
|
|
||||||
const numericTypes = ["int", "integer", "bigint", "smallint", "decimal", "numeric", "float", "double"];
|
|
||||||
if (numericTypes.includes(fromType) && numericTypes.includes(toType)) {
|
|
||||||
return {
|
|
||||||
isValid: true,
|
|
||||||
warnings: ["숫자 타입 간 변환 시 정밀도 손실이 발생할 수 있습니다."],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 문자열 타입 간 호환성
|
|
||||||
const stringTypes = ["varchar", "char", "text", "string"];
|
|
||||||
if (stringTypes.includes(fromType) && stringTypes.includes(toType)) {
|
|
||||||
return {
|
|
||||||
isValid: true,
|
|
||||||
warnings: ["문자열 길이 제한을 확인하세요."],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 날짜/시간 타입 간 호환성
|
|
||||||
const dateTypes = ["date", "datetime", "timestamp", "time"];
|
|
||||||
if (dateTypes.includes(fromType) && dateTypes.includes(toType)) {
|
|
||||||
return {
|
|
||||||
isValid: true,
|
|
||||||
warnings: ["날짜/시간 형식 변환 시 데이터 손실이 발생할 수 있습니다."],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 호환되지 않는 타입
|
|
||||||
return {
|
|
||||||
isValid: false,
|
|
||||||
error: `'${fromColumnType}' 타입과 '${toColumnType}' 타입은 호환되지 않습니다.`,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 데이터 타입 정규화
|
|
||||||
*/
|
|
||||||
const normalizeDataType = (dataType: string): string => {
|
|
||||||
const lowerType = dataType.toLowerCase();
|
|
||||||
|
|
||||||
// 정수 타입
|
|
||||||
if (lowerType.includes("int") || lowerType.includes("serial")) {
|
|
||||||
return "int";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 실수 타입
|
|
||||||
if (
|
|
||||||
lowerType.includes("decimal") ||
|
|
||||||
lowerType.includes("numeric") ||
|
|
||||||
lowerType.includes("float") ||
|
|
||||||
lowerType.includes("double")
|
|
||||||
) {
|
|
||||||
return "decimal";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 문자열 타입
|
|
||||||
if (
|
|
||||||
lowerType.includes("varchar") ||
|
|
||||||
lowerType.includes("char") ||
|
|
||||||
lowerType.includes("text") ||
|
|
||||||
lowerType.includes("string")
|
|
||||||
) {
|
|
||||||
return "varchar";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 날짜 타입
|
|
||||||
if (lowerType.includes("date")) {
|
|
||||||
return "date";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 시간 타입
|
|
||||||
if (lowerType.includes("time")) {
|
|
||||||
return "datetime";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 불린 타입
|
|
||||||
if (lowerType.includes("bool")) {
|
|
||||||
return "boolean";
|
|
||||||
}
|
|
||||||
|
|
||||||
return lowerType;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 매핑 완성도 검증
|
|
||||||
*/
|
|
||||||
export const validateMappingCompleteness = (requiredColumns: string[], mappings: ColumnMapping[]): ValidationResult => {
|
|
||||||
const mappedColumns = mappings.map((m) => m.toColumnName);
|
|
||||||
const unmappedRequired = requiredColumns.filter((col) => !mappedColumns.includes(col));
|
|
||||||
|
|
||||||
if (unmappedRequired.length > 0) {
|
|
||||||
return {
|
|
||||||
isValid: false,
|
|
||||||
error: `필수 컬럼이 매핑되지 않았습니다: ${unmappedRequired.join(", ")}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { isValid: true };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 전체 액션 설정 검증
|
|
||||||
*/
|
|
||||||
export const validateActionConfiguration = (
|
|
||||||
actionType: "insert" | "update" | "delete",
|
|
||||||
fromTable?: string,
|
|
||||||
toTable?: string,
|
|
||||||
mappings?: ColumnMapping[],
|
|
||||||
conditions?: any[],
|
|
||||||
): ValidationResult => {
|
|
||||||
// 기본 필수 정보 체크
|
|
||||||
if (!toTable) {
|
|
||||||
return {
|
|
||||||
isValid: false,
|
|
||||||
error: "대상 테이블을 선택해야 합니다.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 액션 타입별 검증
|
|
||||||
switch (actionType) {
|
|
||||||
case "insert":
|
|
||||||
if (!mappings || mappings.length === 0) {
|
|
||||||
return {
|
|
||||||
isValid: false,
|
|
||||||
error: "INSERT 작업에는 최소 하나의 필드 매핑이 필요합니다.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "update":
|
|
||||||
if (!fromTable) {
|
|
||||||
return {
|
|
||||||
isValid: false,
|
|
||||||
error: "UPDATE 작업에는 조건 확인용 소스 테이블이 필요합니다.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!conditions || conditions.length === 0) {
|
|
||||||
return {
|
|
||||||
isValid: false,
|
|
||||||
error: "UPDATE 작업에는 WHERE 조건이 필요합니다.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "delete":
|
|
||||||
if (!fromTable) {
|
|
||||||
return {
|
|
||||||
isValid: false,
|
|
||||||
error: "DELETE 작업에는 조건 확인용 소스 테이블이 필요합니다.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!conditions || conditions.length === 0) {
|
|
||||||
return {
|
|
||||||
isValid: false,
|
|
||||||
error: "DELETE 작업에는 WHERE 조건이 필요합니다.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { isValid: true };
|
|
||||||
};
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,269 +0,0 @@
|
||||||
# 🔧 제어관리 외부 커넥션 통합 기능 사용 가이드
|
|
||||||
|
|
||||||
## 📋 기능 개요
|
|
||||||
|
|
||||||
제어관리 시스템에 외부 데이터베이스 커넥션 연동 기능이 추가되었습니다. 이제 데이터 저장 액션에서 외부 DB나 자기 자신의 테이블에 INSERT, UPDATE, DELETE 작업을 수행할 수 있습니다.
|
|
||||||
|
|
||||||
## 🚀 주요 기능
|
|
||||||
|
|
||||||
### 1. **다중 커넥션 지원**
|
|
||||||
|
|
||||||
- 메인 데이터베이스 (현재 시스템)
|
|
||||||
- 외부 데이터베이스 (MySQL, PostgreSQL, Oracle, SQL Server 등)
|
|
||||||
- FROM/TO 커넥션을 독립적으로 선택 가능
|
|
||||||
|
|
||||||
### 2. **액션 타입별 지원**
|
|
||||||
|
|
||||||
- **INSERT**: 외부 DB에서 데이터 조회 → 다른 DB에 삽입
|
|
||||||
- **UPDATE**: 조건 확인 후 대상 테이블 업데이트
|
|
||||||
- **DELETE**: 조건 확인 후 대상 테이블 데이터 삭제
|
|
||||||
|
|
||||||
### 3. **자기 자신 테이블 작업**
|
|
||||||
|
|
||||||
- 같은 테이블 내에서 UPDATE/DELETE 작업 지원
|
|
||||||
- 강화된 안전장치로 데이터 손실 방지
|
|
||||||
|
|
||||||
## 📖 사용 방법
|
|
||||||
|
|
||||||
### 1단계: 커넥션 선택
|
|
||||||
|
|
||||||
제어관리에서 데이터 저장 액션을 생성할 때, 먼저 FROM/TO 커넥션을 선택합니다.
|
|
||||||
|
|
||||||
```
|
|
||||||
📍 커넥션 선택 화면
|
|
||||||
┌─────────────────────────────────────────────────────────┐
|
|
||||||
│ FROM 커넥션 (소스) │ TO 커넥션 (대상) │
|
|
||||||
│ ┌─────────────────────────┐ │ ┌─────────────────────────┐ │
|
|
||||||
│ │ [현재 DB] 메인 시스템 │ │ │ [MySQL] 외부 DB 1 │ │
|
|
||||||
│ │ [PostgreSQL] 외부 DB 2 │ │ │ [Oracle] 외부 DB 3 │ │
|
|
||||||
│ └─────────────────────────┘ │ └─────────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2단계: 테이블 선택
|
|
||||||
|
|
||||||
선택한 커넥션에서 사용할 테이블을 선택합니다.
|
|
||||||
|
|
||||||
```
|
|
||||||
📍 테이블 선택 화면
|
|
||||||
┌─────────────────────────────────────────────────────────┐
|
|
||||||
│ FROM 테이블 │ TO 테이블 │
|
|
||||||
│ ┌─────────────────────────┐ │ ┌─────────────────────────┐ │
|
|
||||||
│ │ 🔍 [검색: user] │ │ │ 🔍 [검색: order] │ │
|
|
||||||
│ │ 📊 user_info (15 컬럼) │ │ │ 📊 order_log (8 컬럼) │ │
|
|
||||||
│ │ 📊 user_auth (5 컬럼) │ │ │ 📊 order_items (12 컬럼)│ │
|
|
||||||
│ └─────────────────────────┘ │ └─────────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3단계: 액션별 설정
|
|
||||||
|
|
||||||
#### 🔄 INSERT 액션
|
|
||||||
|
|
||||||
FROM 테이블의 데이터를 TO 테이블에 삽입하는 필드 매핑을 설정합니다.
|
|
||||||
|
|
||||||
```
|
|
||||||
매핑 규칙:
|
|
||||||
✅ 1:1 매핑: FROM.user_id → TO.customer_id
|
|
||||||
✅ 1:N 매핑: FROM.name → TO.first_name, TO.display_name
|
|
||||||
❌ N:1 매핑: FROM.first_name, FROM.last_name → TO.name (금지)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 🔄 UPDATE 액션
|
|
||||||
|
|
||||||
3가지 설정 영역이 있습니다:
|
|
||||||
|
|
||||||
1. **업데이트 조건**: FROM 테이블에서 어떤 조건을 만족할 때 실행할지
|
|
||||||
2. **필드 매핑**: FROM 데이터를 TO 테이블의 어떤 필드에 업데이트할지
|
|
||||||
3. **WHERE 조건**: TO 테이블에서 어떤 레코드를 업데이트할지
|
|
||||||
|
|
||||||
```
|
|
||||||
예시 설정:
|
|
||||||
🔍 업데이트 조건: FROM.status = 'completed' AND FROM.updated_at > '2024-01-01'
|
|
||||||
📝 필드 매핑: FROM.result_value → TO.final_score
|
|
||||||
🎯 WHERE 조건: TO.user_id = FROM.user_id AND TO.status = 'pending'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 🔄 DELETE 액션
|
|
||||||
|
|
||||||
2가지 설정 영역과 안전장치가 있습니다:
|
|
||||||
|
|
||||||
1. **삭제 트리거 조건**: FROM 테이블에서 어떤 조건을 만족할 때 삭제할지
|
|
||||||
2. **WHERE 조건**: TO 테이블에서 어떤 레코드를 삭제할지
|
|
||||||
3. **안전장치**: 최대 삭제 개수, 확인 요구, Dry Run 등
|
|
||||||
|
|
||||||
```
|
|
||||||
예시 설정:
|
|
||||||
🔥 삭제 조건: FROM.is_expired = 'Y' AND FROM.cleanup_date < NOW()
|
|
||||||
🎯 WHERE 조건: TO.ref_id = FROM.id AND TO.status = 'inactive'
|
|
||||||
🛡️ 안전장치: 최대 100개, 확인 요구, Dry Run 실행
|
|
||||||
```
|
|
||||||
|
|
||||||
## ⚠️ 자기 자신 테이블 작업 주의사항
|
|
||||||
|
|
||||||
### UPDATE 작업 시
|
|
||||||
|
|
||||||
```
|
|
||||||
⚠️ 주의사항:
|
|
||||||
- WHERE 조건 필수 설정
|
|
||||||
- 업데이트 조건과 WHERE 조건 겹침 방지
|
|
||||||
- 무한 루프 위험 체크
|
|
||||||
|
|
||||||
✅ 안전한 예시:
|
|
||||||
UPDATE user_info
|
|
||||||
SET last_login = NOW(), login_count = login_count + 1
|
|
||||||
WHERE user_id = 'specific_user' AND status = 'active'
|
|
||||||
```
|
|
||||||
|
|
||||||
### DELETE 작업 시
|
|
||||||
|
|
||||||
```
|
|
||||||
🚨 강화된 안전장치:
|
|
||||||
- 최대 삭제 개수 10개로 제한
|
|
||||||
- 부정 조건(!=, NOT IN, NOT EXISTS) 금지
|
|
||||||
- WHERE 조건 2개 이상 권장
|
|
||||||
|
|
||||||
✅ 안전한 예시:
|
|
||||||
DELETE FROM temp_data
|
|
||||||
WHERE created_at < NOW() - INTERVAL '7 days'
|
|
||||||
AND status = 'processed'
|
|
||||||
AND batch_id = 'specific_batch'
|
|
||||||
LIMIT 10
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🛠️ 실제 사용 시나리오
|
|
||||||
|
|
||||||
### 시나리오 1: 사용자 로그인 정보 업데이트
|
|
||||||
|
|
||||||
```
|
|
||||||
목적: 사용자 로그인 시 마지막 로그인 시간과 로그인 횟수 업데이트
|
|
||||||
|
|
||||||
설정:
|
|
||||||
- FROM: login_logs 테이블 (메인 DB)
|
|
||||||
- TO: user_info 테이블 (메인 DB)
|
|
||||||
- 액션: UPDATE
|
|
||||||
|
|
||||||
조건:
|
|
||||||
🔍 업데이트 조건: login_logs.status = 'success' AND login_logs.created_at > NOW() - INTERVAL '1 minute'
|
|
||||||
📝 필드 매핑:
|
|
||||||
- login_logs.created_at → user_info.last_login
|
|
||||||
- login_logs.user_agent → user_info.last_user_agent
|
|
||||||
🎯 WHERE 조건: user_info.user_id = login_logs.user_id
|
|
||||||
```
|
|
||||||
|
|
||||||
### 시나리오 2: 외부 시스템에서 주문 데이터 동기화
|
|
||||||
|
|
||||||
```
|
|
||||||
목적: 외부 쇼핑몰 시스템의 주문 데이터를 내부 ERP에 저장
|
|
||||||
|
|
||||||
설정:
|
|
||||||
- FROM: orders 테이블 (외부 MySQL DB)
|
|
||||||
- TO: erp_orders 테이블 (메인 PostgreSQL DB)
|
|
||||||
- 액션: INSERT
|
|
||||||
|
|
||||||
매핑:
|
|
||||||
✅ 필드 매핑:
|
|
||||||
- orders.order_id → erp_orders.external_order_id
|
|
||||||
- orders.customer_name → erp_orders.customer_name
|
|
||||||
- orders.total_amount → erp_orders.order_amount
|
|
||||||
- orders.order_date → erp_orders.received_date
|
|
||||||
```
|
|
||||||
|
|
||||||
### 시나리오 3: 만료된 임시 데이터 정리
|
|
||||||
|
|
||||||
```
|
|
||||||
목적: 배치 작업 완료 후 임시 처리 데이터 자동 삭제
|
|
||||||
|
|
||||||
설정:
|
|
||||||
- FROM: batch_jobs 테이블 (메인 DB)
|
|
||||||
- TO: temp_processing_data 테이블 (메인 DB)
|
|
||||||
- 액션: DELETE
|
|
||||||
|
|
||||||
조건:
|
|
||||||
🔥 삭제 조건: batch_jobs.status = 'completed' AND batch_jobs.completed_at < NOW() - INTERVAL '1 hour'
|
|
||||||
🎯 WHERE 조건:
|
|
||||||
- temp_processing_data.batch_id = batch_jobs.batch_id
|
|
||||||
- temp_processing_data.status = 'processed'
|
|
||||||
🛡️ 안전장치: 최대 100개, Dry Run 먼저 실행
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔧 API 사용법
|
|
||||||
|
|
||||||
### 활성 커넥션 조회
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { getActiveConnections } from "@/lib/api/multiConnection";
|
|
||||||
|
|
||||||
const connections = await getActiveConnections();
|
|
||||||
// 결과: [{ id: 0, connection_name: '메인 데이터베이스' }, ...]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 테이블 목록 조회
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { getTablesFromConnection } from "@/lib/api/multiConnection";
|
|
||||||
|
|
||||||
const tables = await getTablesFromConnection(connectionId);
|
|
||||||
// 결과: [{ tableName: 'users', columnCount: 15 }, ...]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 컬럼 정보 조회
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { getColumnsFromConnection } from "@/lib/api/multiConnection";
|
|
||||||
|
|
||||||
const columns = await getColumnsFromConnection(connectionId, "users");
|
|
||||||
// 결과: [{ columnName: 'id', dataType: 'int', isPrimaryKey: true }, ...]
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚨 문제 해결
|
|
||||||
|
|
||||||
### 일반적인 오류
|
|
||||||
|
|
||||||
#### 1. 커넥션 연결 실패
|
|
||||||
|
|
||||||
```
|
|
||||||
오류: "커넥션을 찾을 수 없습니다"
|
|
||||||
해결: 외부 커넥션 관리에서 해당 커넥션이 활성 상태인지 확인
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. 테이블 접근 권한 부족
|
|
||||||
|
|
||||||
```
|
|
||||||
오류: "테이블에 접근할 수 없습니다"
|
|
||||||
해결: 데이터베이스 사용자의 테이블 권한 확인
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. 매핑 제약사항 위반
|
|
||||||
|
|
||||||
```
|
|
||||||
오류: "대상 컬럼이 이미 매핑되어 있습니다"
|
|
||||||
해결: N:1 매핑이 아닌 1:N 매핑으로 변경
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. 자기 자신 테이블 작업 실패
|
|
||||||
|
|
||||||
```
|
|
||||||
오류: "WHERE 조건이 필수입니다"
|
|
||||||
해결: UPDATE/DELETE 작업 시 WHERE 조건 추가
|
|
||||||
```
|
|
||||||
|
|
||||||
### 성능 최적화
|
|
||||||
|
|
||||||
1. **인덱스 활용**: WHERE 조건에 사용되는 컬럼에 인덱스 생성
|
|
||||||
2. **배치 크기 조정**: 대량 데이터 처리 시 적절한 배치 크기 설정
|
|
||||||
3. **커넥션 풀링**: 외부 DB 연결 시 커넥션 풀 최적화
|
|
||||||
|
|
||||||
## 📞 지원
|
|
||||||
|
|
||||||
문제가 발생하거나 추가 기능이 필요한 경우:
|
|
||||||
|
|
||||||
1. **로그 확인**: 브라우저 개발자 도구의 콘솔 탭 확인
|
|
||||||
2. **에러 메시지**: 상세한 에러 메시지와 함께 문의
|
|
||||||
3. **설정 정보**: 사용한 커넥션, 테이블, 액션 타입 정보 제공
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**📝 마지막 업데이트**: 2024년 12월
|
|
||||||
**🔧 버전**: v1.0.0
|
|
||||||
**📧 문의**: 시스템 관리자
|
|
||||||
Loading…
Reference in New Issue