/** * 노드 플로우 검증 유틸리티 * * 감지 가능한 문제: * 1. 병렬 실행 시 동일 테이블/컬럼 충돌 * 2. WHERE 조건 누락 (전체 테이블 삭제/업데이트) * 3. 순환 참조 (무한 루프) * 4. 데이터 소스 타입 불일치 */ export type ValidationSeverity = "error" | "warning" | "info"; export interface FlowValidation { nodeId: string; severity: ValidationSeverity; type: string; message: string; affectedNodes?: string[]; } import type { FlowNode as TypedFlowNode, FlowEdge as TypedFlowEdge } from "@/types/node-editor"; export type FlowNode = TypedFlowNode; export type FlowEdge = TypedFlowEdge; /** * 플로우 전체 검증 */ export function validateFlow(nodes: FlowNode[], edges: FlowEdge[]): FlowValidation[] { const validations: FlowValidation[] = []; // 0. 연결되지 않은 노드 검증 (최우선) validations.push(...detectDisconnectedNodes(nodes, edges)); // 1. 병렬 실행 충돌 검증 validations.push(...detectParallelConflicts(nodes, edges)); // 2. WHERE 조건 누락 검증 validations.push(...detectMissingWhereConditions(nodes)); // 3. 순환 참조 검증 validations.push(...detectCircularReferences(nodes, edges)); // 4. 데이터 소스 타입 불일치 검증 validations.push(...detectDataSourceMismatch(nodes, edges)); return validations; } /** * 연결되지 않은 노드(고아 노드) 감지 */ function detectDisconnectedNodes(nodes: FlowNode[], edges: FlowEdge[]): FlowValidation[] { const validations: FlowValidation[] = []; // 노드가 없으면 검증 스킵 if (nodes.length === 0) { return validations; } // 연결된 노드 ID 수집 const connectedNodeIds = new Set(); for (const edge of edges) { connectedNodeIds.add(edge.source); connectedNodeIds.add(edge.target); } // Comment 노드는 고아 노드여도 괜찮음 (메모 용도) const disconnectedNodes = nodes.filter((node) => !connectedNodeIds.has(node.id) && node.type !== "comment"); // 고아 노드가 있으면 경고 for (const node of disconnectedNodes) { validations.push({ nodeId: node.id, severity: "warning", type: "disconnected-node", message: `"${node.data.displayName || node.type}" 노드가 다른 노드와 연결되어 있지 않습니다. 이 노드는 실행되지 않습니다.`, }); } return validations; } /** * 특정 노드에서 도달 가능한 모든 노드 찾기 (DFS) */ function getReachableNodes(startNodeId: string, allNodes: FlowNode[], edges: FlowEdge[]): FlowNode[] { const reachable = new Set(); const visited = new Set(); function dfs(nodeId: string) { if (visited.has(nodeId)) return; visited.add(nodeId); reachable.add(nodeId); const outgoingEdges = edges.filter((e) => e.source === nodeId); for (const edge of outgoingEdges) { dfs(edge.target); } } dfs(startNodeId); return allNodes.filter((node) => reachable.has(node.id)); } /** * 병렬 실행 시 동일 테이블/컬럼 충돌 감지 */ function detectParallelConflicts(nodes: FlowNode[], edges: FlowEdge[]): FlowValidation[] { const validations: FlowValidation[] = []; // 🆕 연결된 노드만 필터링 (고아 노드 제외) const connectedNodeIds = new Set(); for (const edge of edges) { connectedNodeIds.add(edge.source); connectedNodeIds.add(edge.target); } // 🆕 소스 노드 찾기 const sourceNodes = nodes.filter( (node) => (node.type === "tableSource" || node.type === "externalDBSource" || node.type === "restAPISource") && connectedNodeIds.has(node.id), ); // 각 소스 노드에서 시작하는 플로우별로 검증 for (const sourceNode of sourceNodes) { // 이 소스에서 도달 가능한 모든 노드 찾기 const reachableNodes = getReachableNodes(sourceNode.id, nodes, edges); // 레벨별로 그룹화 const levels = groupNodesByLevel( reachableNodes, edges.filter( (e) => reachableNodes.some((n) => n.id === e.source) && reachableNodes.some((n) => n.id === e.target), ), ); // 각 레벨에서 충돌 검사 for (const [levelNum, levelNodes] of levels.entries()) { const updateNodes = levelNodes.filter((node) => node.type === "updateAction" || node.type === "deleteAction"); if (updateNodes.length < 2) continue; // 🆕 조건 노드로 분기된 노드들인지 확인 // 같은 레벨의 노드들이 조건 노드를 통해 분기되었다면 병렬이 아님 const parentNodes = updateNodes.map((node) => { const incomingEdge = edges.find((e) => e.target === node.id); return incomingEdge ? nodes.find((n) => n.id === incomingEdge.source) : null; }); // 모든 부모 노드가 같은 조건 노드라면 병렬이 아닌 조건 분기 const uniqueParents = new Set(parentNodes.map((p) => p?.id).filter(Boolean)); const isConditionalBranch = uniqueParents.size === 1 && parentNodes[0]?.type === "condition"; if (isConditionalBranch) { // 조건 분기는 순차 실행이므로 병렬 충돌 검사 스킵 continue; } // 같은 테이블을 수정하는 노드들 찾기 const tableMap = new Map(); for (const node of updateNodes) { const tableName = node.data.targetTable || node.data.externalTargetTable; if (tableName) { if (!tableMap.has(tableName)) { tableMap.set(tableName, []); } tableMap.get(tableName)!.push(node); } } // 충돌 검사 for (const [tableName, conflictNodes] of tableMap.entries()) { if (conflictNodes.length > 1) { // 같은 컬럼을 수정하는지 확인 const fieldMap = new Map(); for (const node of conflictNodes) { const fields = node.data.fieldMappings?.map((m: any) => m.targetField) || []; for (const field of fields) { if (!fieldMap.has(field)) { fieldMap.set(field, []); } fieldMap.get(field)!.push(node); } } for (const [field, fieldNodes] of fieldMap.entries()) { if (fieldNodes.length > 1) { validations.push({ nodeId: fieldNodes[0].id, severity: "warning", type: "parallel-conflict", message: `병렬 실행 중 '${tableName}.${field}' 컬럼에 대한 충돌 가능성이 있습니다. 실행 순서가 보장되지 않아 예상치 못한 결과가 발생할 수 있습니다.`, affectedNodes: fieldNodes.map((n) => n.id), }); } } // 같은 테이블에 대한 일반 경고 if (conflictNodes.length > 1 && fieldMap.size === 0) { validations.push({ nodeId: conflictNodes[0].id, severity: "info", type: "parallel-table-access", message: `병렬 실행 중 '${tableName}' 테이블을 동시에 수정합니다.`, affectedNodes: conflictNodes.map((n) => n.id), }); } } } } } return validations; } /** * WHERE 조건 누락 감지 */ function detectMissingWhereConditions(nodes: FlowNode[]): FlowValidation[] { const validations: FlowValidation[] = []; for (const node of nodes) { if (node.type === "updateAction" || node.type === "deleteAction") { const whereConditions = node.data.whereConditions; if (!whereConditions || whereConditions.length === 0) { validations.push({ nodeId: node.id, severity: "error", type: "missing-where", message: `WHERE 조건 없이 전체 테이블을 ${node.type === "deleteAction" ? "삭제" : "수정"}합니다. 매우 위험합니다!`, }); } } } return validations; } /** * 순환 참조 감지 (무한 루프) */ function detectCircularReferences(nodes: FlowNode[], edges: FlowEdge[]): FlowValidation[] { const validations: FlowValidation[] = []; // 인접 리스트 생성 const adjacencyList = new Map(); for (const node of nodes) { adjacencyList.set(node.id, []); } for (const edge of edges) { adjacencyList.get(edge.source)?.push(edge.target); } // DFS로 순환 참조 찾기 const visited = new Set(); const recursionStack = new Set(); const cycles: string[][] = []; function dfs(nodeId: string, path: string[]): void { visited.add(nodeId); recursionStack.add(nodeId); path.push(nodeId); const neighbors = adjacencyList.get(nodeId) || []; for (const neighbor of neighbors) { if (!visited.has(neighbor)) { dfs(neighbor, [...path]); } else if (recursionStack.has(neighbor)) { // 순환 참조 발견 const cycleStart = path.indexOf(neighbor); const cycle = path.slice(cycleStart); cycles.push([...cycle, neighbor]); } } recursionStack.delete(nodeId); } for (const node of nodes) { if (!visited.has(node.id)) { dfs(node.id, []); } } // 순환 참조 경고 생성 for (const cycle of cycles) { const nodeNames = cycle .map((id) => { const node = nodes.find((n) => n.id === id); return node?.data.displayName || node?.type || id; }) .join(" → "); validations.push({ nodeId: cycle[0], severity: "error", type: "circular-reference", message: `순환 참조가 감지되었습니다: ${nodeNames}`, affectedNodes: cycle, }); } return validations; } /** * 데이터 소스 타입 불일치 감지 */ function detectDataSourceMismatch(nodes: FlowNode[], edges: FlowEdge[]): FlowValidation[] { const validations: FlowValidation[] = []; // 각 노드의 데이터 소스 타입 추적 const nodeDataSourceTypes = new Map(); // Source 노드들의 타입 수집 for (const node of nodes) { if (node.type === "tableSource" || node.type === "externalDBSource") { const dataSourceType = node.data.dataSourceType || "context-data"; nodeDataSourceTypes.set(node.id, dataSourceType); } } // 각 엣지를 따라 데이터 소스 타입 전파 for (const edge of edges) { const sourceType = nodeDataSourceTypes.get(edge.source); if (sourceType) { nodeDataSourceTypes.set(edge.target, sourceType); } } // Action 노드들 검사 for (const node of nodes) { if (node.type === "updateAction" || node.type === "deleteAction" || node.type === "insertAction") { const dataSourceType = nodeDataSourceTypes.get(node.id); // table-all 모드인데 WHERE에 특정 레코드 조건이 있는 경우 if (dataSourceType === "table-all") { const whereConditions = node.data.whereConditions || []; const hasPrimaryKeyCondition = whereConditions.some((cond: any) => cond.field === "id"); if (hasPrimaryKeyCondition) { validations.push({ nodeId: node.id, severity: "warning", type: "data-source-mismatch", message: `데이터 소스가 'table-all'이지만 WHERE 조건에 Primary Key가 포함되어 있습니다. 의도한 동작인지 확인하세요.`, }); } } } } return validations; } /** * 레벨별로 노드 그룹화 (위상 정렬) */ function groupNodesByLevel(nodes: FlowNode[], edges: FlowEdge[]): Map { const levels = new Map(); const nodeLevel = new Map(); const inDegree = new Map(); const adjacencyList = new Map(); // 초기화 for (const node of nodes) { inDegree.set(node.id, 0); adjacencyList.set(node.id, []); } // 인접 리스트 및 진입 차수 계산 for (const edge of edges) { adjacencyList.get(edge.source)?.push(edge.target); inDegree.set(edge.target, (inDegree.get(edge.target) || 0) + 1); } // BFS로 레벨 계산 const queue: string[] = []; for (const [nodeId, degree] of inDegree.entries()) { if (degree === 0) { queue.push(nodeId); nodeLevel.set(nodeId, 0); } } while (queue.length > 0) { const currentId = queue.shift()!; const currentLevel = nodeLevel.get(currentId)!; const neighbors = adjacencyList.get(currentId) || []; for (const neighbor of neighbors) { const newDegree = (inDegree.get(neighbor) || 0) - 1; inDegree.set(neighbor, newDegree); if (newDegree === 0) { queue.push(neighbor); nodeLevel.set(neighbor, currentLevel + 1); } } } // 레벨별로 노드 그룹화 for (const node of nodes) { const level = nodeLevel.get(node.id) || 0; if (!levels.has(level)) { levels.set(level, []); } levels.get(level)!.push(node); } return levels; } /** * 검증 결과 요약 */ export function summarizeValidations(validations: FlowValidation[]): { errorCount: number; warningCount: number; infoCount: number; hasBlockingIssues: boolean; } { const errorCount = validations.filter((v) => v.severity === "error").length; const warningCount = validations.filter((v) => v.severity === "warning").length; const infoCount = validations.filter((v) => v.severity === "info").length; return { errorCount, warningCount, infoCount, hasBlockingIssues: errorCount > 0, }; } /** * 특정 노드의 검증 결과 가져오기 */ export function getNodeValidations(nodeId: string, validations: FlowValidation[]): FlowValidation[] { return validations.filter((v) => v.nodeId === nodeId || v.affectedNodes?.includes(nodeId)); }