Skip to content
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@
- [FE] 🗑️ **모든 노드 지우기 기능 추가**: 캔버스의 모든 테이블 노드와 관계를 한 번에 초기화하는 버튼을 툴바에 추가했습니다.
- [FE] 📋 **테이블 복제 기능 추가**: 편집 모달 내에서 기존 테이블의 구조(컬럼 정보 포함)를 그대로 복사하여 새 테이블 노드로 생성하는 '복제' 버튼을 추가했습니다.
- [FE] `autoInfer.ts`에 대한 단위 테스트 및 UI 컴포넌트 단위 테스트를 추가하여 100% 테스트 커버리지를 유지합니다.

- [FE] ⬇️ **DBML Export**: ERD 다이어그램을 DBML (Database Markup Language) 형식으로 내보낼 수 있는 기능을 추가했습니다. 상단의 DBML 버튼을 클릭하여 다운로드할 수 있습니다.
38 changes: 32 additions & 6 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
} from "./erd/export";
import { exportMermaid } from "./erd/mermaid";
import { inferRelationships } from "./erd/autoInfer";
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 All @@ -69,6 +70,16 @@ const TERMINAL_SNAPSHOT_STATUSES = new Set([

const SUPPORTED_DSN_PROTOCOLS = new Set(["postgres:", "postgresql:", "snowflake:"]);

function sanitizeHtml(str: string | null | undefined): string {
if (!str) return "";
return String(str)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}

type CurrentUser = {
subject: string;
display_name: string | null;
Expand Down Expand Up @@ -626,6 +637,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 @@ -1244,7 +1259,7 @@ export default function App() {
}}
>
<span className="projectCard__icon" aria-hidden="true" />
<strong>{project.project_name}</strong>
<strong>{sanitizeHtml(project.project_name)}</strong>
<span>다이어그램 보기</span>
</button>
))}
Expand All @@ -1263,7 +1278,7 @@ export default function App() {
</div>
<DiagramTable
snapshots={recentSnapshots}
selectedProjectName={selectedProject?.project_name}
selectedProjectName={sanitizeHtml(selectedProject?.project_name)}
onOpenEditor={(id) => {
setSnapshotId(id);
setSnapshot(null);
Expand Down Expand Up @@ -1302,7 +1317,7 @@ export default function App() {
</div>
{projects.map((project) => (
<div className="dataTable__row dataTable__row--projects" role="row" key={project.project_space_uuid}>
<strong role="cell">{project.project_name}</strong>
<strong role="cell">{sanitizeHtml(project.project_name)}</strong>
<span role="cell">{project.project_space_uuid === selectedProjectId ? connections.length : "선택 후 표시"}</span>
<span role="cell">
<button
Expand Down Expand Up @@ -1346,7 +1361,7 @@ export default function App() {
<DiagramTable
snapshots={snapshots}
searchText={diagramSearch}
selectedProjectName={selectedProject?.project_name}
selectedProjectName={sanitizeHtml(selectedProject?.project_name)}
onOpenEditor={(id) => {
setSnapshotId(id);
setSnapshot(null);
Expand Down Expand Up @@ -1507,6 +1522,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 Expand Up @@ -1698,8 +1724,8 @@ function DiagramTable({
<strong role="cell">{name}</strong>
<span role="cell">{selectedProjectName || "현재 프로젝트"}</span>
<span role="cell">
<span className={`statusPill statusPill--${item.status}`}>
{item.status}
<span className={`statusPill statusPill--${sanitizeHtml(item.status)}`}>
{sanitizeHtml(item.status)}
</span>
</span>
<span role="cell">
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";
}