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";
+}