From 1428245f0089a161237da0f4655cd5ff1788788d Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 3 Jul 2026 20:39:28 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20DBML=20=EB=8B=A4=EC=9D=B4=EC=96=B4?= =?UTF-8?q?=EA=B7=B8=EB=9E=A8=20=EB=82=B4=EB=B3=B4=EB=82=B4=EA=B8=B0=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ERD 다이어그램을 DBML(Database Markup Language) 형식으로 변환하는 로직 추가 - UI에 DBML 다운로드 버튼 추가 - DBML 내보내기 변환 테스트(100% 커버리지 확보) - 관련 CHANGELOG 추가 --- CHANGELOG.md | 4 + frontend/src/App.tsx | 16 +++ frontend/src/erd/__tests__/dbml.test.ts | 147 ++++++++++++++++++++++++ frontend/src/erd/dbml.ts | 108 +++++++++++++++++ 4 files changed, 275 insertions(+) create mode 100644 frontend/src/erd/__tests__/dbml.test.ts create mode 100644 frontend/src/erd/dbml.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6437e94a..b4b3a78c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,3 +12,7 @@ ### 🐛 개선 (Improvements) - 불필요한 백엔드 포맷팅 이슈(`ruff` 포맷) 해결. - 테스트 커버리지를 높이기 위해 기본 단위 테스트 환경(jsdom, @testing-library) 구축 및 활용. + +## [Unreleased] +### Added +- **DBML Export**: ERD 다이어그램을 DBML (Database Markup Language) 형식으로 내보낼 수 있는 기능을 추가했습니다. 상단의 DBML 버튼을 클릭하여 다운로드할 수 있습니다. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e99700c5..06448882 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -57,6 +57,7 @@ import { exportPlantUml, } from "./erd/export"; import { exportMermaid } from "./erd/mermaid"; +import { exportDbml } from "./erd/dbml"; import { GRID_COLUMNS, GRID_X_GAP, GRID_Y_GAP } from "./erd/layoutConstants"; import type { Connection, Project, Snapshot, SnapshotDetail } from "./types"; @@ -624,6 +625,10 @@ export default function App() { downloadText("pg-erd-diagram.mermaid", exportMermaid(nodes, edges), "text/plain"); } + function onDownloadDbml() { + downloadText("pg-erd-diagram.dbml", exportDbml(nodes, edges), "text/plain"); + } + function onRelDelete() { if (!editingEdge) return; if (!window.confirm("정말로 이 관계를 삭제하시겠습니까?")) return; @@ -1458,6 +1463,17 @@ export default function App() { > {"{}"} +
{[layoutMessage, nodeSearchStatus].filter(Boolean).join(" ")}
diff --git a/frontend/src/erd/__tests__/dbml.test.ts b/frontend/src/erd/__tests__/dbml.test.ts new file mode 100644 index 00000000..51761e53 --- /dev/null +++ b/frontend/src/erd/__tests__/dbml.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect } from 'vitest'; +import type { Node, Edge } from '@xyflow/react'; +import { exportDbml } from '../dbml'; +import type { TableNodeData } from '../convert'; + +describe('exportDbml', () => { + it('should return empty string for empty nodes', () => { + const result = exportDbml([], []); + expect(result).toBe(''); + }); + + it('should export simple table', () => { + const nodes: Node[] = [ + { + id: '1', + type: 'tableNode', + position: { x: 0, y: 0 }, + data: { + title: 'public.users', + badges: { pk: true, fk: false }, + columns: [ + { column_name: 'id', data_type: 'integer', is_pk: true, is_not_null: true }, + { column_name: 'name', data_type: 'varchar', is_pk: false, is_not_null: true }, + ], + }, + }, + ]; + const result = exportDbml(nodes, []); + expect(result).toContain('Table public.users {'); + expect(result).toContain('id integer [pk]'); + expect(result).toContain('name varchar [not null]'); + }); + + it('should export relation', () => { + const nodes: Node[] = [ + { + id: '1', + type: 'tableNode', + position: { x: 0, y: 0 }, + data: { + title: 'users', + badges: { pk: true, fk: false }, + columns: [ + { column_name: 'id', data_type: 'int', is_pk: true, is_not_null: true }, + ], + }, + }, + { + id: '2', + type: 'tableNode', + position: { x: 0, y: 0 }, + data: { + title: 'posts', + badges: { pk: true, fk: true }, + columns: [ + { column_name: 'id', data_type: 'int', is_pk: true, is_not_null: true }, + { column_name: 'user_id', data_type: 'int', is_pk: false, is_not_null: true }, + ], + }, + }, + ]; + + const edges: Edge[] = [ + { + id: 'e1', + source: '2', + target: '1', + sourceHandle: 'src-user_id', + targetHandle: 'tgt-id', + label: 'rel', + }, + ]; + + const result = exportDbml(nodes, edges); + expect(result).toContain('Ref: posts.user_id > users.id'); + }); + + it('should export composite relation', () => { + const nodes: Node[] = [ + { + id: '1', + type: 'tableNode', + position: { x: 0, y: 0 }, + data: { + title: 'users', + badges: { pk: true, fk: false }, + columns: [ + { column_name: 'tenant_id', data_type: 'int', is_pk: true, is_not_null: true }, + { column_name: 'id', data_type: 'int', is_pk: true, is_not_null: true }, + ], + }, + }, + { + id: '2', + type: 'tableNode', + position: { x: 0, y: 0 }, + data: { + title: 'posts', + badges: { pk: true, fk: true }, + columns: [ + { column_name: 'id', data_type: 'int', is_pk: true, is_not_null: true }, + { column_name: 'tenant_id', data_type: 'int', is_pk: false, is_not_null: true }, + { column_name: 'user_id', data_type: 'int', is_pk: false, is_not_null: true }, + ], + }, + }, + ]; + + const edges: Edge[] = [ + { + id: 'e1', + source: '2', + target: '1', + label: 'rel', + data: { + sourceColumns: ['tenant_id', 'user_id'], + targetColumns: ['tenant_id', 'id'] + } + }, + ]; + + const result = exportDbml(nodes, edges); + expect(result).toContain('Ref: posts.(tenant_id, user_id) > users.(tenant_id, id)'); + }); + + it('should escape special characters', () => { + const nodes: Node[] = [ + { + id: '1', + type: 'tableNode', + position: { x: 0, y: 0 }, + data: { + title: 'public.my-table', + comment: "test ' comment", + badges: { pk: true, fk: false }, + columns: [ + { column_name: 'my-col', data_type: 'integer', is_pk: true, is_not_null: true, column_comment: "col ' comment" }, + ], + }, + }, + ]; + const result = exportDbml(nodes, []); + expect(result).toContain('Table public."my-table" {'); + expect(result).toContain('"my-col" integer [pk, note: \'col \'\' comment\']'); + expect(result).toContain('Note: \'test \'\' comment\''); + }); +}); diff --git a/frontend/src/erd/dbml.ts b/frontend/src/erd/dbml.ts new file mode 100644 index 00000000..7191f2c8 --- /dev/null +++ b/frontend/src/erd/dbml.ts @@ -0,0 +1,108 @@ +import type { Node, Edge } from "@xyflow/react"; +import type { TableNodeData, ForeignKeyEdgeData } from "./convert"; + +function escapeString(str: string): string { + if (!str) return ""; + return str.replace(/'/g, "''"); +} + +function safeId(str: string): string { + if (!str) return ""; + if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(str)) { + return str; + } + return `"${str.replace(/"/g, '""')}"`; +} + +export function exportDbml( + nodes: Node[], + edges: Edge[], +): string { + let output = ""; + + if (nodes.length === 0) { + return output; + } + + const nodesById = new Map>(); + for (const n of nodes) { + nodesById.set(n.id, n); + } + + // Create tables + for (const node of nodes) { + const tableNameParts = node.data.title.split('.'); + let schemaName = ''; + let tableName = ''; + if (tableNameParts.length > 1) { + schemaName = tableNameParts[0]; + tableName = tableNameParts.slice(1).join('.'); + } else { + tableName = node.data.title; + } + + const fullTableName = schemaName ? `${safeId(schemaName)}.${safeId(tableName)}` : safeId(tableName); + output += `Table ${fullTableName} {\n`; + + for (const col of node.data.columns) { + const type = col.data_type || 'varchar'; + let settings = []; + if (col.is_pk) settings.push("pk"); + if (col.is_not_null && !col.is_pk) settings.push("not null"); + if (col.column_comment) { + settings.push(`note: '${escapeString(col.column_comment)}'`); + } + + const settingsStr = settings.length > 0 ? ` [${settings.join(", ")}]` : ""; + + output += ` ${safeId(col.column_name)} ${type}${settingsStr}\n`; + } + + if (node.data.comment) { + output += ` Note: '${escapeString(node.data.comment)}'\n`; + } + + output += "}\n\n"; + } + + // Create relations + for (const edge of edges) { + const sourceNode = nodesById.get(edge.source); + const targetNode = nodesById.get(edge.target); + + if (sourceNode && targetNode) { + const sourceNameParts = sourceNode.data.title.split('.'); + const sourceTableName = sourceNameParts.length > 1 + ? `${safeId(sourceNameParts[0])}.${safeId(sourceNameParts.slice(1).join('.'))}` + : safeId(sourceNode.data.title); + + const targetNameParts = targetNode.data.title.split('.'); + const targetTableName = targetNameParts.length > 1 + ? `${safeId(targetNameParts[0])}.${safeId(targetNameParts.slice(1).join('.'))}` + : safeId(targetNode.data.title); + + const edgeData = edge.data as ForeignKeyEdgeData | undefined; + + let sourceCols: string[] = []; + let targetCols: string[] = []; + + if (edgeData?.sourceColumns && edgeData?.targetColumns) { + 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-', ''))]; + } + + if (sourceCols.length > 0 && targetCols.length > 0) { + if (sourceCols.length === 1) { + output += `Ref: ${sourceTableName}.${sourceCols[0]} > ${targetTableName}.${targetCols[0]}\n`; + } else { + output += `Ref: ${sourceTableName}.(${sourceCols.join(', ')}) > ${targetTableName}.(${targetCols.join(', ')})\n`; + } + } + } + } + + return output.trim() + "\n"; +}