Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,7 @@
### 🐛 개선 (Improvements)
- 불필요한 백엔드 포맷팅 이슈(`ruff` 포맷) 해결.
- 테스트 커버리지를 높이기 위해 기본 단위 테스트 환경(jsdom, @testing-library) 구축 및 활용.

## [Unreleased]
### Added
- **DBML Export**: ERD 다이어그램을 DBML (Database Markup Language) 형식으로 내보낼 수 있는 기능을 추가했습니다. 상단의 DBML 버튼을 클릭하여 다운로드할 수 있습니다.
16 changes: 16 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1458,6 +1463,17 @@ export default function App() {
>
{"{}"}
</button>
<button
type="button"
onClick={onDownloadDbml}
disabled={nodes.length === 0}
title={
nodes.length === 0 ? "내보낼 테이블이 없습니다" : "DBML 내보내기"
}
aria-label="DBML 내보내기"
>
DBML
</button>
<div className="srOnly" aria-live="polite">
{[layoutMessage, nodeSearchStatus].filter(Boolean).join(" ")}
</div>
Expand Down
147 changes: 147 additions & 0 deletions frontend/src/erd/__tests__/dbml.test.ts
Original file line number Diff line number Diff line change
@@ -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<TableNodeData>[] = [
{
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<TableNodeData>[] = [
{
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<TableNodeData>[] = [
{
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<TableNodeData>[] = [
{
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\'');
});
});
108 changes: 108 additions & 0 deletions frontend/src/erd/dbml.ts
Original file line number Diff line number Diff line change
@@ -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<TableNodeData>[],
edges: Edge[],
): string {
let output = "";

if (nodes.length === 0) {
return output;
}

const nodesById = new Map<string, Node<TableNodeData>>();
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";
}