From 29a4c1fbd854580db2ec27b0a8eee3eafbb7761e Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:32:28 +0000 Subject: [PATCH 1/5] =?UTF-8?q?fix(frontend):=20DBML/Mermaid/Prisma=20Expo?= =?UTF-8?q?rt=20Handle=20Parsing=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ERD 다이어그램을 DBML, Mermaid, Prisma 형식으로 내보낼 때, Edge Handle에 CSS selector 호환을 위해 인코딩된 컬럼명을 그대로 사용하여 문자열이 깨지는 문제를 해결했습니다. `parseColumnNameFromHandle` 유틸리티를 추가하여 올바른 컬럼 이름으로 디코딩하며, 삭제된 컬럼을 참조하는 dangling edge를 무시하도록 안전 장치를 추가했습니다. --- CHANGELOG.md | 1 + .../src/erd/__tests__/coverageEdges.test.ts | 2 +- frontend/src/erd/__tests__/dbml.test.ts | 4 +-- frontend/src/erd/__tests__/prisma.test.ts | 8 ++--- frontend/src/erd/dbml.ts | 13 +++++-- frontend/src/erd/handleUtils.test.ts | 35 ++++++++++++++++++- frontend/src/erd/handleUtils.ts | 14 ++++++++ frontend/src/erd/mermaid.ts | 16 +++++++-- frontend/src/erd/prisma.ts | 15 ++++++-- 9 files changed, 92 insertions(+), 16 deletions(-) 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..c50037936 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', }, ]; 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..1f77d0660 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, "''"); @@ -89,8 +90,16 @@ export function exportDbml( 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..3687a0250 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,37 @@ 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(''); + }); + }); }); diff --git a/frontend/src/erd/handleUtils.ts b/frontend/src/erd/handleUtils.ts index 054d5ab2a..8ca4c0854 100644 --- a/frontend/src/erd/handleUtils.ts +++ b/frontend/src/erd/handleUtils.ts @@ -14,3 +14,17 @@ 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 ''; + + return hexParts.map(hex => { + const codePoint = parseInt(hex, 16); + if (isNaN(codePoint)) return ''; + return String.fromCodePoint(codePoint); + }).join(''); +} diff --git a/frontend/src/erd/mermaid.ts b/frontend/src/erd/mermaid.ts index 6e77a9bca..2af11cfb2 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 ""; @@ -30,13 +30,23 @@ export function exportMermaid( for (const edge of edges) { if (edge.sourceHandle?.startsWith("src-")) { - fkNodeColumnPairs.add(`${edge.source}:${edge.sourceHandle.slice(4)}`); + const parsedSource = parseColumnNameFromHandle(edge.sourceHandle); + const sourceNode = nodesById.get(edge.source); + const sourceExists = sourceNode && (sourceNode.data.columns || []).some(c => c && c.column_name === parsedSource); + if (sourceExists) { + 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); + const targetExists = targetNode && (targetNode.data.columns || []).some(c => c && c.column_name === parsedTarget); + if (targetExists) { + fkNodeColumnPairs.add(`${edge.target}:${sanitizeHandleId(parsedTarget)}`); + } } } diff --git a/frontend/src/erd/prisma.ts b/frontend/src/erd/prisma.ts index 211dfdd8d..add021690 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 { @@ -70,15 +71,23 @@ export function exportPrisma( let sourceField = ""; if (edge.sourceHandle?.startsWith("src-")) { - sourceField = edge.sourceHandle.slice(4); - fkNodeColumnPairs.add(`${edge.source}:${sourceField}`); + const parsedSource = parseColumnNameFromHandle(edge.sourceHandle); + const sourceExists = (sourceNode.data.columns || []).some(c => c && c.column_name === parsedSource); + if (sourceExists) { + sourceField = parsedSource; + fkNodeColumnPairs.add(`${edge.source}:${sanitizeHandleId(parsedSource)}`); + } } else if (!edge.sourceHandle) { fkNodesWithoutHandles.add(edge.source); } let targetField = "id"; // fallback if (edge.targetHandle?.startsWith("tgt-")) { - targetField = edge.targetHandle.slice(4); + const parsedTarget = parseColumnNameFromHandle(edge.targetHandle); + const targetExists = (targetNode.data.columns || []).some(c => c && c.column_name === parsedTarget); + if (targetExists) { + targetField = parsedTarget; + } } if (sourceField) { From 5c907d17dcb3e71ef48392c0b3f1814a75f7d393 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:50:45 +0000 Subject: [PATCH 2/5] =?UTF-8?q?fix(frontend):=20DBML/Mermaid/Prisma=20Expo?= =?UTF-8?q?rt=20Handle=20Parsing=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ERD 다이어그램을 DBML, Mermaid, Prisma 형식으로 내보낼 때, Edge Handle에 CSS selector 호환을 위해 인코딩된 컬럼명을 그대로 사용하여 문자열이 깨지는 문제를 해결했습니다. `parseColumnNameFromHandle` 유틸리티를 추가하여 올바른 컬럼 이름으로 디코딩하며, 삭제된 컬럼을 참조하는 dangling edge를 무시하도록 안전 장치를 추가했습니다. From 1175da71cfbb09c6fbe3e074ba12edbe86bff82d Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:50:53 +0000 Subject: [PATCH 3/5] =?UTF-8?q?fix(frontend):=20DBML/Mermaid/Prisma=20Expo?= =?UTF-8?q?rt=20Handle=20Parsing=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ERD 다이어그램을 DBML, Mermaid, Prisma 형식으로 내보낼 때, Edge Handle에 CSS selector 호환을 위해 인코딩된 컬럼명을 그대로 사용하여 문자열이 깨지는 문제를 해결했습니다. `parseColumnNameFromHandle` 유틸리티를 추가하여 올바른 컬럼 이름으로 디코딩하며, 삭제된 컬럼을 참조하는 dangling edge를 무시하도록 안전 장치를 추가했습니다. From 8a68a1742c4a56a85cee0e784690c71d58076588 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:02:41 +0000 Subject: [PATCH 4/5] =?UTF-8?q?fix(frontend):=20DBML/Mermaid/Prisma=20Expo?= =?UTF-8?q?rt=20Handle=20Parsing=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ERD 다이어그램을 DBML, Mermaid, Prisma 형식으로 내보낼 때, Edge Handle에 CSS selector 호환을 위해 인코딩된 컬럼명을 그대로 사용하여 문자열이 깨지는 문제를 해결했습니다. `parseColumnNameFromHandle` 유틸리티를 추가하여 올바른 컬럼 이름으로 디코딩하며, 삭제된 컬럼을 참조하는 dangling edge를 무시하도록 안전 장치를 추가했습니다. From ff01d9c76895a2c8b4cb9aa8b4ec706c4c0202a7 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:22:40 +0000 Subject: [PATCH 5/5] =?UTF-8?q?fix(frontend):=20DBML/Mermaid/Prisma=20Expo?= =?UTF-8?q?rt=20Handle=20Parsing=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ERD 다이어그램을 DBML, Mermaid, Prisma 형식으로 내보낼 때, Edge Handle에 CSS selector 호환을 위해 인코딩된 컬럼명을 그대로 사용하여 문자열이 깨지는 문제를 해결했습니다. parseColumnNameFromHandle 유틸리티를 추가하여 올바른 컬럼 이름으로 디코딩하며, 삭제된 컬럼을 참조하는 dangling edge를 무시하도록 안전 장치를 추가했습니다. --- frontend/src/erd/__tests__/dbml.test.ts | 4 ++-- frontend/src/erd/dbml.ts | 9 +++++++-- frontend/src/erd/handleUtils.test.ts | 6 ++++++ frontend/src/erd/handleUtils.ts | 17 +++++++++++++---- frontend/src/erd/mermaid.ts | 18 ++++++++++++++---- frontend/src/erd/prisma.ts | 14 +++++++++----- 6 files changed, 51 insertions(+), 17 deletions(-) diff --git a/frontend/src/erd/__tests__/dbml.test.ts b/frontend/src/erd/__tests__/dbml.test.ts index c50037936..9d730bfee 100644 --- a/frontend/src/erd/__tests__/dbml.test.ts +++ b/frontend/src/erd/__tests__/dbml.test.ts @@ -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/dbml.ts b/frontend/src/erd/dbml.ts index 1f77d0660..1b062b676 100644 --- a/frontend/src/erd/dbml.ts +++ b/frontend/src/erd/dbml.ts @@ -87,8 +87,13 @@ 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) { const parsedSource = parseColumnNameFromHandle(edge.sourceHandle); const parsedTarget = parseColumnNameFromHandle(edge.targetHandle); diff --git a/frontend/src/erd/handleUtils.test.ts b/frontend/src/erd/handleUtils.test.ts index 3687a0250..9241fc139 100644 --- a/frontend/src/erd/handleUtils.test.ts +++ b/frontend/src/erd/handleUtils.test.ts @@ -67,5 +67,11 @@ describe('handleUtils', () => { 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 8ca4c0854..660a10fcb 100644 --- a/frontend/src/erd/handleUtils.ts +++ b/frontend/src/erd/handleUtils.ts @@ -22,9 +22,18 @@ export function parseColumnNameFromHandle(handleId: string): string { const hexParts = prefixRemoved.slice(2).split('-'); if (hexParts.length === 1 && hexParts[0] === 'empty') return ''; - return hexParts.map(hex => { + const decoded = hexParts.map(hex => { + if (!/^[0-9a-fA-F]+$/.test(hex)) return null; const codePoint = parseInt(hex, 16); - if (isNaN(codePoint)) return ''; - return String.fromCodePoint(codePoint); - }).join(''); + 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 2af11cfb2..b9f8999b3 100644 --- a/frontend/src/erd/mermaid.ts +++ b/frontend/src/erd/mermaid.ts @@ -28,12 +28,17 @@ 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-")) { const parsedSource = parseColumnNameFromHandle(edge.sourceHandle); const sourceNode = nodesById.get(edge.source); - const sourceExists = sourceNode && (sourceNode.data.columns || []).some(c => c && c.column_name === parsedSource); - if (sourceExists) { + sourceValid = !!sourceNode && (sourceNode.data.columns || []).some(c => c && c.column_name === parsedSource); + if (sourceValid) { fkNodeColumnPairs.add(`${edge.source}:${sanitizeHandleId(parsedSource)}`); } } else if (!edge.sourceHandle) { @@ -43,11 +48,15 @@ export function exportMermaid( if (edge.targetHandle?.startsWith("tgt-")) { const parsedTarget = parseColumnNameFromHandle(edge.targetHandle); const targetNode = nodesById.get(edge.target); - const targetExists = targetNode && (targetNode.data.columns || []).some(c => c && c.column_name === parsedTarget); - if (targetExists) { + 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); + } } for (const node of nodes) { @@ -74,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 add021690..0891c3764 100644 --- a/frontend/src/erd/prisma.ts +++ b/frontend/src/erd/prisma.ts @@ -70,10 +70,11 @@ 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-")) { const parsedSource = parseColumnNameFromHandle(edge.sourceHandle); - const sourceExists = (sourceNode.data.columns || []).some(c => c && c.column_name === parsedSource); - if (sourceExists) { + sourceValid = (sourceNode.data.columns || []).some(c => c && c.column_name === parsedSource); + if (sourceValid) { sourceField = parsedSource; fkNodeColumnPairs.add(`${edge.source}:${sanitizeHandleId(parsedSource)}`); } @@ -82,15 +83,18 @@ export function exportPrisma( } let targetField = "id"; // fallback + let targetValid = true; if (edge.targetHandle?.startsWith("tgt-")) { const parsedTarget = parseColumnNameFromHandle(edge.targetHandle); - const targetExists = (targetNode.data.columns || []).some(c => c && c.column_name === parsedTarget); - if (targetExists) { + 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) || [];