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