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