From 41f40ac216550c470593b9ed9d9a66588c85a925 Mon Sep 17 00:00:00 2001 From: hyeonsu Date: Mon, 15 Sep 2025 11:17:46 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EA=B7=B8=EB=A3=B9?= =?UTF-8?q?=ED=95=91=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/eventTriggerService.ts | 119 +- .../dataflow/ConnectionSetupModal.tsx | 1075 +++++++++++------ frontend/lib/api/dataflow.ts | 10 +- 3 files changed, 819 insertions(+), 385 deletions(-) diff --git a/backend-node/src/services/eventTriggerService.ts b/backend-node/src/services/eventTriggerService.ts index 254149f7..03ded0a4 100644 --- a/backend-node/src/services/eventTriggerService.ts +++ b/backend-node/src/services/eventTriggerService.ts @@ -5,19 +5,21 @@ const prisma = new PrismaClient(); // 조건 노드 타입 정의 interface ConditionNode { - type: "group" | "condition"; - operator?: "AND" | "OR"; - children?: ConditionNode[]; + id: string; // 고유 ID + type: "condition" | "group-start" | "group-end"; field?: string; operator_type?: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE"; value?: any; dataType?: string; + logicalOperator?: "AND" | "OR"; // 다음 조건과의 논리 연산자 + groupId?: string; // 그룹 ID (group-start와 group-end가 같은 groupId를 가짐) + groupLevel?: number; // 중첩 레벨 (0, 1, 2, ...) } // 조건 제어 정보 interface ConditionControl { triggerType: "insert" | "update" | "delete" | "insert_update"; - conditionTree: ConditionNode | null; + conditionTree: ConditionNode | ConditionNode[] | null; } // 연결 카테고리 정보 @@ -237,32 +239,103 @@ export class EventTriggerService { } /** - * 조건 평가 + * 조건 평가 (플랫 구조 + 그룹핑 지원) */ private static async evaluateCondition( - condition: ConditionNode, + condition: ConditionNode | ConditionNode[], data: Record ): Promise { - if (condition.type === "group") { - if (!condition.children || condition.children.length === 0) { - return true; + // 단일 조건인 경우 (하위 호환성) + if (!Array.isArray(condition)) { + if (condition.type === "condition") { + return this.evaluateSingleCondition(condition, data); } - - const results = await Promise.all( - condition.children.map((child) => this.evaluateCondition(child, data)) - ); - - if (condition.operator === "OR") { - return results.some((result) => result); - } else { - // AND - return results.every((result) => result); - } - } else if (condition.type === "condition") { - return this.evaluateSingleCondition(condition, data); + return true; } - return false; + // 조건 배열인 경우 (새로운 그룹핑 시스템) + return this.evaluateConditionList(condition, data); + } + + /** + * 조건 리스트 평가 (괄호 그룹핑 지원) + */ + private static async evaluateConditionList( + conditions: ConditionNode[], + data: Record + ): Promise { + if (conditions.length === 0) { + return true; + } + + // 조건을 평가 가능한 표현식으로 변환 + const expression = await this.buildConditionExpression(conditions, data); + + // 표현식 평가 + return this.evaluateExpression(expression); + } + + /** + * 조건들을 평가 가능한 표현식으로 변환 + */ + private static async buildConditionExpression( + conditions: ConditionNode[], + data: Record + ): Promise { + const tokens: string[] = []; + + for (let i = 0; i < conditions.length; i++) { + const condition = conditions[i]; + + if (condition.type === "group-start") { + // 이전 조건과의 논리 연산자 추가 + if (i > 0 && condition.logicalOperator) { + tokens.push(condition.logicalOperator); + } + tokens.push("("); + } else if (condition.type === "group-end") { + tokens.push(")"); + } else if (condition.type === "condition") { + // 이전 조건과의 논리 연산자 추가 + if (i > 0 && condition.logicalOperator) { + tokens.push(condition.logicalOperator); + } + + // 조건 평가 결과를 토큰으로 추가 + const result = await this.evaluateSingleCondition(condition, data); + tokens.push(result.toString()); + } + } + + return tokens.join(" "); + } + + /** + * 논리 표현식 평가 (괄호 우선순위 지원) + */ + private static evaluateExpression(expression: string): boolean { + try { + // 안전한 논리 표현식 평가 + // true/false와 AND/OR/괄호만 포함된 표현식을 평가 + const sanitizedExpression = expression + .replace(/\bAND\b/g, "&&") + .replace(/\bOR\b/g, "||") + .replace(/\btrue\b/g, "true") + .replace(/\bfalse\b/g, "false"); + + // 보안을 위해 허용된 문자만 확인 + if (!/^[true|false|\s|&|\||\(|\)]+$/.test(sanitizedExpression)) { + logger.warn(`Invalid expression: ${expression}`); + return false; + } + + // Function constructor를 사용한 안전한 평가 + const result = new Function(`return ${sanitizedExpression}`)(); + return Boolean(result); + } catch (error) { + logger.error(`Error evaluating expression: ${expression}`, error); + return false; + } } /** diff --git a/frontend/components/dataflow/ConnectionSetupModal.tsx b/frontend/components/dataflow/ConnectionSetupModal.tsx index 3b1cae8e..ab2c8d13 100644 --- a/frontend/components/dataflow/ConnectionSetupModal.tsx +++ b/frontend/components/dataflow/ConnectionSetupModal.tsx @@ -62,17 +62,12 @@ interface DataSaveSettings { id: string; name: string; actionType: "insert" | "update" | "delete" | "upsert"; - conditions?: Array<{ - field: string; - operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE"; - value: string; - logicalOperator?: "AND" | "OR"; - }>; + conditions?: ConditionNode[]; fieldMappings: Array<{ sourceTable?: string; - sourceField: string; + sourceField: string; targetTable?: string; - targetField: string; + targetField: string; defaultValue?: string; transformFunction?: string; }>; @@ -353,14 +348,7 @@ export const ConnectionSetupModal: React.FC = ({ ? { control: { triggerType: "insert", - conditionTree: - conditions.length > 0 - ? { - type: "group" as const, - operator: "AND" as const, - children: conditions, - } - : null, + conditionTree: conditions.length > 0 ? conditions : null, }, category: { type: config.connectionType, @@ -446,19 +434,87 @@ export const ConnectionSetupModal: React.FC = ({ return config.connectionType === "data-save" || config.connectionType === "external-call"; }; + // 고유 ID 생성 헬퍼 + const generateId = () => `cond_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + // 조건 관리 헬퍼 함수들 const addCondition = () => { const newCondition: ConditionNode = { + id: generateId(), type: "condition", field: "", operator_type: "=", value: "", dataType: "string", - operator: "AND", // 기본값으로 AND 설정 + logicalOperator: "AND", // 기본값으로 AND 설정 }; setConditions([...conditions, newCondition]); }; + // 그룹 시작 추가 + const addGroupStart = () => { + const groupId = `group_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const groupLevel = getNextGroupLevel(); + + const groupStart: ConditionNode = { + id: generateId(), + type: "group-start", + groupId, + groupLevel, + logicalOperator: conditions.length > 0 ? "AND" : undefined, + }; + + setConditions([...conditions, groupStart]); + }; + + // 그룹 끝 추가 + const addGroupEnd = () => { + // 가장 최근에 열린 그룹 찾기 + const openGroups = findOpenGroups(); + if (openGroups.length === 0) { + toast.error("닫을 그룹이 없습니다."); + return; + } + + const lastOpenGroup = openGroups[openGroups.length - 1]; + const groupEnd: ConditionNode = { + id: generateId(), + type: "group-end", + groupId: lastOpenGroup.groupId, + groupLevel: lastOpenGroup.groupLevel, + }; + + setConditions([...conditions, groupEnd]); + }; + + // 다음 그룹 레벨 계산 + const getNextGroupLevel = (): number => { + const openGroups = findOpenGroups(); + return openGroups.length; + }; + + // 열린 그룹 찾기 + const findOpenGroups = () => { + const openGroups: Array<{ groupId: string; groupLevel: number }> = []; + + for (const condition of conditions) { + if (condition.type === "group-start") { + openGroups.push({ + groupId: condition.groupId!, + groupLevel: condition.groupLevel!, + }); + } else if (condition.type === "group-end") { + // 해당 그룹 제거 + const groupIndex = openGroups.findIndex((g) => g.groupId === condition.groupId); + if (groupIndex !== -1) { + openGroups.splice(groupIndex, 1); + } + } + } + + return openGroups; + }; + const updateCondition = (index: number, field: keyof ConditionNode, value: string) => { const updatedConditions = [...conditions]; updatedConditions[index] = { ...updatedConditions[index], [field]: value }; @@ -466,10 +522,381 @@ export const ConnectionSetupModal: React.FC = ({ }; const removeCondition = (index: number) => { - const updatedConditions = conditions.filter((_, i) => i !== index); + const conditionToRemove = conditions[index]; + + // 그룹 시작/끝을 삭제하는 경우 해당 그룹 전체 삭제 + if (conditionToRemove.type === "group-start" || conditionToRemove.type === "group-end") { + removeGroup(conditionToRemove.groupId!); + } else { + const updatedConditions = conditions.filter((_, i) => i !== index); + setConditions(updatedConditions); + } + }; + + // 그룹 전체 삭제 + const removeGroup = (groupId: string) => { + const updatedConditions = conditions.filter((c) => c.groupId !== groupId); setConditions(updatedConditions); }; + // 현재 조건의 그룹 레벨 계산 + const getCurrentGroupLevel = (conditionIndex: number): number => { + let level = 0; + for (let i = 0; i < conditionIndex; i++) { + const condition = conditions[i]; + if (condition.type === "group-start") { + level++; + } else if (condition.type === "group-end") { + level--; + } + } + return level; + }; + + // 액션별 조건 그룹 관리 함수들 + const addActionGroupStart = (actionIndex: number) => { + const groupId = `action_group_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const currentConditions = dataSaveSettings.actions[actionIndex].conditions || []; + const groupLevel = getActionNextGroupLevel(currentConditions); + + const groupStart: ConditionNode = { + id: generateId(), + type: "group-start", + groupId, + groupLevel, + logicalOperator: currentConditions.length > 0 ? "AND" : undefined, + }; + + const newActions = [...dataSaveSettings.actions]; + newActions[actionIndex].conditions = [...currentConditions, groupStart]; + setDataSaveSettings({ ...dataSaveSettings, actions: newActions }); + }; + + const addActionGroupEnd = (actionIndex: number) => { + const currentConditions = dataSaveSettings.actions[actionIndex].conditions || []; + const openGroups = findActionOpenGroups(currentConditions); + + if (openGroups.length === 0) { + toast.error("닫을 그룹이 없습니다."); + return; + } + + const lastOpenGroup = openGroups[openGroups.length - 1]; + const groupEnd: ConditionNode = { + id: generateId(), + type: "group-end", + groupId: lastOpenGroup.groupId, + groupLevel: lastOpenGroup.groupLevel, + }; + + const newActions = [...dataSaveSettings.actions]; + newActions[actionIndex].conditions = [...currentConditions, groupEnd]; + setDataSaveSettings({ ...dataSaveSettings, actions: newActions }); + }; + + // 액션별 다음 그룹 레벨 계산 + const getActionNextGroupLevel = (conditions: ConditionNode[]): number => { + const openGroups = findActionOpenGroups(conditions); + return openGroups.length; + }; + + // 액션별 열린 그룹 찾기 + const findActionOpenGroups = (conditions: ConditionNode[]) => { + const openGroups: Array<{ groupId: string; groupLevel: number }> = []; + + for (const condition of conditions) { + if (condition.type === "group-start") { + openGroups.push({ + groupId: condition.groupId!, + groupLevel: condition.groupLevel!, + }); + } else if (condition.type === "group-end") { + const groupIndex = openGroups.findIndex((g) => g.groupId === condition.groupId); + if (groupIndex !== -1) { + openGroups.splice(groupIndex, 1); + } + } + } + + return openGroups; + }; + + // 액션별 현재 조건의 그룹 레벨 계산 + const getActionCurrentGroupLevel = (conditions: ConditionNode[], conditionIndex: number): number => { + let level = 0; + for (let i = 0; i < conditionIndex; i++) { + const condition = conditions[i]; + if (condition.type === "group-start") { + level++; + } else if (condition.type === "group-end") { + level--; + } + } + return level; + }; + + // 액션별 조건 렌더링 함수 + const renderActionCondition = (condition: ConditionNode, condIndex: number, actionIndex: number) => { + // 그룹 시작 렌더링 + if (condition.type === "group-start") { + return ( +
+ {condIndex > 0 && ( + + )} +
+ ( + 그룹 시작 + +
+
+ ); + } + + // 그룹 끝 렌더링 + if (condition.type === "group-end") { + return ( +
+
+ ) + 그룹 끝 + +
+
+ ); + } + + // 일반 조건 렌더링 (기존 로직 간소화) + return ( +
+ {condIndex > 0 && ( + + )} +
+ + + {/* 데이터 타입에 따른 동적 입력 컴포넌트 */} + {(() => { + const selectedColumn = fromTableColumns.find((col) => col.columnName === condition.field); + const dataType = selectedColumn?.dataType?.toLowerCase() || "string"; + + if (dataType.includes("timestamp") || dataType.includes("datetime") || dataType.includes("date")) { + return ( + { + const newActions = [...dataSaveSettings.actions]; + newActions[actionIndex].conditions![condIndex].value = e.target.value; + setDataSaveSettings({ ...dataSaveSettings, actions: newActions }); + }} + className="h-6 flex-1 text-xs" + /> + ); + } else if (dataType.includes("time")) { + return ( + { + const newActions = [...dataSaveSettings.actions]; + newActions[actionIndex].conditions![condIndex].value = e.target.value; + setDataSaveSettings({ ...dataSaveSettings, actions: newActions }); + }} + className="h-6 flex-1 text-xs" + /> + ); + } else if (dataType.includes("date")) { + return ( + { + const newActions = [...dataSaveSettings.actions]; + newActions[actionIndex].conditions![condIndex].value = e.target.value; + setDataSaveSettings({ ...dataSaveSettings, actions: newActions }); + }} + className="h-6 flex-1 text-xs" + /> + ); + } else if ( + dataType.includes("int") || + dataType.includes("numeric") || + dataType.includes("decimal") || + dataType.includes("float") || + dataType.includes("double") + ) { + return ( + { + const newActions = [...dataSaveSettings.actions]; + newActions[actionIndex].conditions![condIndex].value = e.target.value; + setDataSaveSettings({ ...dataSaveSettings, actions: newActions }); + }} + className="h-6 flex-1 text-xs" + /> + ); + } else if (dataType.includes("bool")) { + return ( + + ); + } else { + return ( + { + const newActions = [...dataSaveSettings.actions]; + newActions[actionIndex].conditions![condIndex].value = e.target.value; + setDataSaveSettings({ ...dataSaveSettings, actions: newActions }); + }} + className="h-6 flex-1 text-xs" + /> + ); + } + })()} + +
+
+ ); + }; + // 조건부 연결 설정 UI 렌더링 const renderConditionalSettings = () => { return ( @@ -483,10 +910,18 @@ export const ConnectionSetupModal: React.FC = ({
- +
+ + + +
{/* 조건 목록 */} @@ -498,139 +933,217 @@ export const ConnectionSetupModal: React.FC = ({ 조건이 없으면 항상 실행됩니다.
) : ( - conditions.map((condition, index) => ( -
- {/* 첫 번째 조건이 아닐 때 AND/OR 연산자를 앞에 표시 */} - {index > 0 && ( - - )} - - {/* 조건 필드들 */} - - - - - {/* 데이터 타입에 따른 적절한 입력 컴포넌트 */} - {(() => { - const selectedColumn = fromTableColumns.find((col) => col.columnName === condition.field); - const dataType = selectedColumn?.dataType?.toLowerCase() || "string"; - - if (dataType.includes("timestamp") || dataType.includes("datetime") || dataType.includes("date")) { - return ( - updateCondition(index, "value", e.target.value)} - className="h-8 flex-1 text-xs" - /> - ); - } else if (dataType.includes("time")) { - return ( - updateCondition(index, "value", e.target.value)} - className="h-8 flex-1 text-xs" - /> - ); - } else if (dataType.includes("date")) { - return ( - updateCondition(index, "value", e.target.value)} - className="h-8 flex-1 text-xs" - /> - ); - } else if ( - dataType.includes("int") || - dataType.includes("numeric") || - dataType.includes("decimal") || - dataType.includes("float") || - dataType.includes("double") - ) { - return ( - updateCondition(index, "value", e.target.value)} - className="h-8 flex-1 text-xs" - /> - ); - } else if (dataType.includes("bool")) { - return ( + conditions.map((condition, index) => { + // 그룹 시작 렌더링 + if (condition.type === "group-start") { + return ( +
+ {/* 이전 조건과의 논리 연산자 */} + {index > 0 && ( - ); - } else { - return ( - updateCondition(index, "value", e.target.value)} - className="h-8 flex-1 text-xs" - /> - ); - } - })()} + )} - -
- )) + {/* 그룹 레벨에 따른 들여쓰기 */} +
+ ( + 그룹 시작 + +
+
+ ); + } + + // 그룹 끝 렌더링 + if (condition.type === "group-end") { + return ( +
+
+ ) + 그룹 끝 + +
+
+ ); + } + + // 일반 조건 렌더링 + return ( +
+ {/* 이전 조건과의 논리 연산자 */} + {index > 0 && ( + + )} + + {/* 그룹 레벨에 따른 들여쓰기와 조건 필드들 */} +
+ {/* 조건 필드 선택 */} + + + {/* 연산자 선택 */} + + + {/* 데이터 타입에 따른 동적 입력 컴포넌트 */} + {(() => { + const selectedColumn = fromTableColumns.find((col) => col.columnName === condition.field); + const dataType = selectedColumn?.dataType?.toLowerCase() || "string"; + + if ( + dataType.includes("timestamp") || + dataType.includes("datetime") || + dataType.includes("date") + ) { + return ( + updateCondition(index, "value", e.target.value)} + className="h-8 flex-1 text-xs" + /> + ); + } else if (dataType.includes("time")) { + return ( + updateCondition(index, "value", e.target.value)} + className="h-8 flex-1 text-xs" + /> + ); + } else if (dataType.includes("date")) { + return ( + updateCondition(index, "value", e.target.value)} + className="h-8 flex-1 text-xs" + /> + ); + } else if ( + dataType.includes("int") || + dataType.includes("numeric") || + dataType.includes("decimal") || + dataType.includes("float") || + dataType.includes("double") + ) { + return ( + updateCondition(index, "value", e.target.value)} + className="h-8 flex-1 text-xs" + /> + ); + } else if (dataType.includes("bool")) { + return ( + + ); + } else { + return ( + updateCondition(index, "value", e.target.value)} + className="h-8 flex-1 text-xs" + /> + ); + } + })()} + + {/* 삭제 버튼 */} + +
+
+ ); + }) )} @@ -675,7 +1188,7 @@ export const ConnectionSetupModal: React.FC = ({
{/* 액션 목록 */} -
+
-
+
{/* 액션 타입 */} -
+
{ - const newActions = [...dataSaveSettings.actions]; - newActions[actionIndex].conditions![condIndex - 1].logicalOperator = value; - setDataSaveSettings({ ...dataSaveSettings, actions: newActions }); - }} - > - - - - - AND - OR - - - )} - - {/* 조건 필드들 */} - - - {/* 데이터 타입에 따른 적절한 입력 컴포넌트 */} - {(() => { - const selectedColumn = fromTableColumns.find( - (col) => col.columnName === condition.field, - ); - const dataType = selectedColumn?.dataType?.toLowerCase() || "string"; - - if ( - dataType.includes("timestamp") || - dataType.includes("datetime") || - dataType.includes("date") - ) { - return ( - { - const newActions = [...dataSaveSettings.actions]; - newActions[actionIndex].conditions![condIndex].value = e.target.value; - setDataSaveSettings({ ...dataSaveSettings, actions: newActions }); - }} - className="h-6 flex-1 text-xs" - /> - ); - } else if (dataType.includes("time")) { - return ( - { - const newActions = [...dataSaveSettings.actions]; - newActions[actionIndex].conditions![condIndex].value = e.target.value; - setDataSaveSettings({ ...dataSaveSettings, actions: newActions }); - }} - className="h-6 flex-1 text-xs" - /> - ); - } else if (dataType.includes("date")) { - return ( - { - const newActions = [...dataSaveSettings.actions]; - newActions[actionIndex].conditions![condIndex].value = e.target.value; - setDataSaveSettings({ ...dataSaveSettings, actions: newActions }); - }} - className="h-6 flex-1 text-xs" - /> - ); - } else if ( - dataType.includes("int") || - dataType.includes("numeric") || - dataType.includes("decimal") || - dataType.includes("float") || - dataType.includes("double") - ) { - return ( - { - const newActions = [...dataSaveSettings.actions]; - newActions[actionIndex].conditions![condIndex].value = e.target.value; - setDataSaveSettings({ ...dataSaveSettings, actions: newActions }); - }} - className="h-6 flex-1 text-xs" - /> - ); - } else if (dataType.includes("bool")) { - return ( - - ); - } else { - return ( - { - const newActions = [...dataSaveSettings.actions]; - newActions[actionIndex].conditions![condIndex].value = e.target.value; - setDataSaveSettings({ ...dataSaveSettings, actions: newActions }); - }} - className="h-6 flex-1 text-xs" - /> - ); - } - })()} - -
- ))} -
+ {action.conditions.map((condition, condIndex) => + renderActionCondition(condition, condIndex, actionIndex), + )} +
)} -
+
@@ -1043,7 +1402,7 @@ export const ConnectionSetupModal: React.FC = ({
-
+