447 lines
14 KiB
TypeScript
447 lines
14 KiB
TypeScript
/**
|
|
* 노드 플로우 검증 유틸리티
|
|
*
|
|
* 감지 가능한 문제:
|
|
* 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<string>();
|
|
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<string>();
|
|
const visited = new Set<string>();
|
|
|
|
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<string>();
|
|
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<string, FlowNode[]>();
|
|
|
|
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<string, FlowNode[]>();
|
|
|
|
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<string, string[]>();
|
|
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<string>();
|
|
const recursionStack = new Set<string>();
|
|
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<string, string>();
|
|
|
|
// 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<number, FlowNode[]> {
|
|
const levels = new Map<number, FlowNode[]>();
|
|
const nodeLevel = new Map<string, number>();
|
|
const inDegree = new Map<string, number>();
|
|
const adjacencyList = new Map<string, string[]>();
|
|
|
|
// 초기화
|
|
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));
|
|
}
|