diff --git a/CHANGELOG.md b/CHANGELOG.md index 35613431a..7435b8bf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,3 +13,4 @@ - [FE] `autoInfer.ts`에 대한 단위 테스트 및 UI 컴포넌트 단위 테스트를 추가하여 100% 테스트 커버리지를 유지합니다. - [FE] ⬇️ **DBML Export**: ERD 다이어그램을 DBML (Database Markup Language) 형식으로 내보낼 수 있는 기능을 추가했습니다. 상단의 DBML 버튼을 클릭하여 다운로드할 수 있습니다. - [FE] 📚 **Data Dictionary Export**: ERD 테이블/컬럼 메타데이터를 CSV 및 Markdown으로 내보내며, CSV formula injection과 Markdown 렌더링 escape를 적용했습니다. +- [FE] 🐛 **DBML, Mermaid, Prisma Export Handle Parsing 수정**: Edge handle에 인코딩된 컬럼명을 올바르게 디코딩하는 `parseColumnNameFromHandle` 유틸리티를 추가하여 Export 시 컬럼 이름이 깨지는 문제를 해결했습니다. 또한, 삭제된 컬럼을 참조하는 dangling edge를 안전하게 무시하도록 검증 로직을 보완했습니다. diff --git a/frontend/src/erd/__tests__/coverageEdges.test.ts b/frontend/src/erd/__tests__/coverageEdges.test.ts index 0dedc39e4..8cbaae92e 100644 --- a/frontend/src/erd/__tests__/coverageEdges.test.ts +++ b/frontend/src/erd/__tests__/coverageEdges.test.ts @@ -50,7 +50,7 @@ describe('coverage edge contracts', () => { { id: 'missing', source: 'missing', target: 'parent' }, { id: 'partial-data', source: 'child', target: 'parent', data: { sourceColumns: ['parent_id'] } }, { id: 'empty-data', source: 'child', target: 'parent', data: { sourceColumns: [], targetColumns: [] } }, - { id: 'handles', source: 'child', target: 'parent', sourceHandle: 'src-parent_id', targetHandle: 'tgt-' }, + { id: 'handles', source: 'child', target: 'parent', sourceHandle: 'src-c-0070-0061-0072-0065-006e-0074-005f-0069-0064', targetHandle: 'tgt-empty' }, ] const dbml = exportDbml([parent, child, node('empty', '', [])], edges) diff --git a/frontend/src/erd/__tests__/dbml.test.ts b/frontend/src/erd/__tests__/dbml.test.ts index 4634f7673..9d730bfee 100644 --- a/frontend/src/erd/__tests__/dbml.test.ts +++ b/frontend/src/erd/__tests__/dbml.test.ts @@ -65,8 +65,8 @@ describe('exportDbml', () => { id: 'e1', source: '2', target: '1', - sourceHandle: 'src-user_id', - targetHandle: 'tgt-id', + sourceHandle: 'src-c-0075-0073-0065-0072-005f-0069-0064', + targetHandle: 'tgt-c-0069-0064', label: 'rel', }, ]; @@ -126,11 +126,11 @@ describe('exportDbml', () => { it('exports a schema-qualified source to an unqualified target', () => { const source = { id: 'source', type: 'tableNode', position: { x: 0, y: 0 }, - data: { title: 'audit.events', badges: { pk: false, fk: true }, columns: [] }, + data: { title: 'audit.events', badges: { pk: false, fk: true }, columns: [{ column_name: 'user_id', data_type: 'int' }] }, } as Node; const target = { id: 'target', type: 'tableNode', position: { x: 0, y: 0 }, - data: { title: 'users', badges: { pk: true, fk: false }, columns: [] }, + data: { title: 'users', badges: { pk: true, fk: false }, columns: [{ column_name: 'id', data_type: 'int' }] }, } as Node; expect(exportDbml([source, target], [{ id: 'edge', source: 'source', target: 'target', diff --git a/frontend/src/erd/__tests__/prisma.test.ts b/frontend/src/erd/__tests__/prisma.test.ts index 73bca1a66..5570947fb 100644 --- a/frontend/src/erd/__tests__/prisma.test.ts +++ b/frontend/src/erd/__tests__/prisma.test.ts @@ -63,8 +63,8 @@ describe('exportPrisma', () => { id: 'e1', source: '2', target: '1', - sourceHandle: 'src-user_id', - targetHandle: 'tgt-id', + sourceHandle: 'src-c-0075-0073-0065-0072-005f-0069-0064', + targetHandle: 'tgt-c-0069-0064', label: 'users_posts', }, ]; @@ -224,8 +224,8 @@ describe('exportPrisma', () => { id: 'e1', source: '2', target: '1', - sourceHandle: 'src-user_id', - targetHandle: 'tgt-id', + sourceHandle: 'src-c-0075-0073-0065-0072-005f-0069-0064', + targetHandle: 'tgt-c-0069-0064', label: '1to1', }, ]; diff --git a/frontend/src/erd/dbml.ts b/frontend/src/erd/dbml.ts index 4a0c20292..1b062b676 100644 --- a/frontend/src/erd/dbml.ts +++ b/frontend/src/erd/dbml.ts @@ -1,5 +1,6 @@ import type { Node, Edge } from "@xyflow/react"; import type { TableNodeData, ForeignKeyEdgeData } from "./convert"; +import { parseColumnNameFromHandle } from "./handleUtils"; function escapeString(str: string): string { return str.replace(/'/g, "''"); @@ -86,11 +87,24 @@ export function exportDbml( let targetCols: string[] = []; if (edgeData?.sourceColumns && edgeData?.targetColumns) { - sourceCols = edgeData.sourceColumns.map(safeId); - targetCols = edgeData.targetColumns.map(safeId); + const sourceExists = edgeData.sourceColumns.every(col => (sourceNode.data.columns || []).some(c => c && c.column_name === col)); + const targetExists = edgeData.targetColumns.every(col => (targetNode.data.columns || []).some(c => c && c.column_name === col)); + + if (sourceExists && targetExists) { + sourceCols = edgeData.sourceColumns.map(safeId); + targetCols = edgeData.targetColumns.map(safeId); + } } else if (edge.sourceHandle && edge.targetHandle) { - sourceCols = [safeId(edge.sourceHandle.replace('src-', ''))]; - targetCols = [safeId(edge.targetHandle.replace('tgt-', ''))]; + const parsedSource = parseColumnNameFromHandle(edge.sourceHandle); + const parsedTarget = parseColumnNameFromHandle(edge.targetHandle); + + const sourceExists = (sourceNode.data.columns || []).some(c => c && c.column_name === parsedSource); + const targetExists = (targetNode.data.columns || []).some(c => c && c.column_name === parsedTarget); + + if (sourceExists && targetExists) { + sourceCols = [safeId(parsedSource)]; + targetCols = [safeId(parsedTarget)]; + } } if (sourceCols.length > 0 && targetCols.length > 0) { diff --git a/frontend/src/erd/handleUtils.test.ts b/frontend/src/erd/handleUtils.test.ts index 0278739ee..9241fc139 100644 --- a/frontend/src/erd/handleUtils.test.ts +++ b/frontend/src/erd/handleUtils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { sanitizeHandleId, sourceColumnHandleId, targetColumnHandleId } from './handleUtils'; +import { sanitizeHandleId, sourceColumnHandleId, targetColumnHandleId, parseColumnNameFromHandle } from './handleUtils'; describe('handleUtils', () => { describe('sanitizeHandleId', () => { @@ -35,4 +35,43 @@ describe('handleUtils', () => { expect(targetColumnHandleId('id')).toBe('tgt-c-0069-0064'); }); }); + + describe('parseColumnNameFromHandle', () => { + it('should parse a simple ascii string from src handle', () => { + expect(parseColumnNameFromHandle('src-c-0069-0064')).toBe('id'); + }); + + it('should parse a simple ascii string from tgt handle', () => { + expect(parseColumnNameFromHandle('tgt-c-0069-0064')).toBe('id'); + }); + + it('should handle empty string', () => { + expect(parseColumnNameFromHandle('src-c-empty')).toBe(''); + }); + + it('should handle special characters', () => { + expect(parseColumnNameFromHandle('src-c-0075-0073-0065-0072-005f-0069-0064')).toBe('user_id'); + }); + + it('should handle unicode characters', () => { + expect(parseColumnNameFromHandle('tgt-c-0069-0064-005f-ac00')).toBe('id_가'); + }); + + it('should handle emojis', () => { + expect(parseColumnNameFromHandle('src-c-0069-0064-005f-1f680')).toBe('id_🚀'); + }); + + it('should handle invalid or non-c- prefixed strings safely', () => { + expect(parseColumnNameFromHandle('src-invalid')).toBe(''); + expect(parseColumnNameFromHandle('just_a_string')).toBe(''); + expect(parseColumnNameFromHandle('')).toBe(''); + expect(parseColumnNameFromHandle('src-c-invalid')).toBe(''); + }); + + it('should reject malformed segments and scalars out of bounds', () => { + expect(parseColumnNameFromHandle('src-c-0069g')).toBe(''); + expect(parseColumnNameFromHandle('src-c-110000')).toBe(''); + expect(parseColumnNameFromHandle('src-c-D800')).toBe(''); + }); + }); }); diff --git a/frontend/src/erd/handleUtils.ts b/frontend/src/erd/handleUtils.ts index 054d5ab2a..660a10fcb 100644 --- a/frontend/src/erd/handleUtils.ts +++ b/frontend/src/erd/handleUtils.ts @@ -14,3 +14,26 @@ export function sourceColumnHandleId(columnName: string): string { export function targetColumnHandleId(columnName: string): string { return `tgt-${sanitizeHandleId(columnName)}` } + +export function parseColumnNameFromHandle(handleId: string): string { + if (!handleId) return ''; + const prefixRemoved = handleId.replace(/^(src|tgt)-/, ''); + if (!prefixRemoved.startsWith('c-')) return ''; + const hexParts = prefixRemoved.slice(2).split('-'); + if (hexParts.length === 1 && hexParts[0] === 'empty') return ''; + + const decoded = hexParts.map(hex => { + if (!/^[0-9a-fA-F]+$/.test(hex)) return null; + const codePoint = parseInt(hex, 16); + if (isNaN(codePoint) || codePoint < 0 || codePoint > 0x10FFFF) return null; + if (codePoint >= 0xD800 && codePoint <= 0xDFFF) return null; + try { + return String.fromCodePoint(codePoint); + } catch { + return null; + } + }); + + if (decoded.some(char => char === null)) return ''; + return decoded.join(''); +} diff --git a/frontend/src/erd/mermaid.ts b/frontend/src/erd/mermaid.ts index 6e77a9bca..b9f8999b3 100644 --- a/frontend/src/erd/mermaid.ts +++ b/frontend/src/erd/mermaid.ts @@ -1,6 +1,6 @@ import type { Node, Edge } from "@xyflow/react"; import type { TableNodeData } from "./convert"; -import { sanitizeHandleId } from "./handleUtils"; +import { sanitizeHandleId, parseColumnNameFromHandle } from "./handleUtils"; function sanitizeString(str: string): string { if (!str) return ""; @@ -28,15 +28,34 @@ export function exportMermaid( const fkNodeColumnPairs = new Set(); const fkNodesWithoutHandles = new Set(); + const validEdges = new Set(); + for (const edge of edges) { + let sourceValid = true; + let targetValid = true; + if (edge.sourceHandle?.startsWith("src-")) { - fkNodeColumnPairs.add(`${edge.source}:${edge.sourceHandle.slice(4)}`); + const parsedSource = parseColumnNameFromHandle(edge.sourceHandle); + const sourceNode = nodesById.get(edge.source); + sourceValid = !!sourceNode && (sourceNode.data.columns || []).some(c => c && c.column_name === parsedSource); + if (sourceValid) { + fkNodeColumnPairs.add(`${edge.source}:${sanitizeHandleId(parsedSource)}`); + } } else if (!edge.sourceHandle) { fkNodesWithoutHandles.add(edge.source); } if (edge.targetHandle?.startsWith("tgt-")) { - fkNodeColumnPairs.add(`${edge.target}:${edge.targetHandle.slice(4)}`); + const parsedTarget = parseColumnNameFromHandle(edge.targetHandle); + const targetNode = nodesById.get(edge.target); + targetValid = !!targetNode && (targetNode.data.columns || []).some(c => c && c.column_name === parsedTarget); + if (targetValid) { + fkNodeColumnPairs.add(`${edge.target}:${sanitizeHandleId(parsedTarget)}`); + } + } + + if (sourceValid && targetValid) { + validEdges.add(edge.id); } } @@ -64,6 +83,7 @@ export function exportMermaid( } for (const edge of edges) { + if (!validEdges.has(edge.id)) continue; // ⚡ Bolt: O(1) lookups instead of O(N) array search for every edge const sourceNode = nodesById.get(edge.source); const targetNode = nodesById.get(edge.target); diff --git a/frontend/src/erd/prisma.ts b/frontend/src/erd/prisma.ts index 211dfdd8d..0891c3764 100644 --- a/frontend/src/erd/prisma.ts +++ b/frontend/src/erd/prisma.ts @@ -1,5 +1,6 @@ import type { Node, Edge } from "@xyflow/react"; import type { TableNodeData } from "./convert"; +import { parseColumnNameFromHandle } from "./handleUtils"; import { sanitizeHandleId } from "./handleUtils"; function sanitizeName(name: string): string { @@ -69,19 +70,31 @@ export function exportPrisma( const relName = sanitizeName(String(edge.label || `${sourceNode.data.title}_${targetNode.data.title}`)); let sourceField = ""; + let sourceValid = true; if (edge.sourceHandle?.startsWith("src-")) { - sourceField = edge.sourceHandle.slice(4); - fkNodeColumnPairs.add(`${edge.source}:${sourceField}`); + const parsedSource = parseColumnNameFromHandle(edge.sourceHandle); + sourceValid = (sourceNode.data.columns || []).some(c => c && c.column_name === parsedSource); + if (sourceValid) { + sourceField = parsedSource; + fkNodeColumnPairs.add(`${edge.source}:${sanitizeHandleId(parsedSource)}`); + } } else if (!edge.sourceHandle) { fkNodesWithoutHandles.add(edge.source); } let targetField = "id"; // fallback + let targetValid = true; if (edge.targetHandle?.startsWith("tgt-")) { - targetField = edge.targetHandle.slice(4); + const parsedTarget = parseColumnNameFromHandle(edge.targetHandle); + targetValid = (targetNode.data.columns || []).some(c => c && c.column_name === parsedTarget); + if (targetValid) { + targetField = parsedTarget; + } + } else if (edge.targetHandle) { + targetValid = false; // Present but undecodable } - if (sourceField) { + if (sourceField && sourceValid && targetValid) { const isUnique = sourceNode.data.columns.find(c => c.column_name === sourceField)?.is_pk || false; const relList = incomingRelationsByNode.get(edge.target) || [];