diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a632832..519bee6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,3 +6,4 @@ - [FE] 📋 **테이블 복제 기능 추가**: 편집 모달 내에서 기존 테이블의 구조(컬럼 정보 포함)를 그대로 복사하여 새 테이블 노드로 생성하는 '복제' 버튼을 추가했습니다. - [FE] `autoInfer.ts`에 대한 단위 테스트 및 UI 컴포넌트 단위 테스트를 추가하여 100% 테스트 커버리지를 유지합니다. - [FE] ⬇️ **DBML Export**: ERD 다이어그램을 DBML (Database Markup Language) 형식으로 내보낼 수 있는 기능을 추가했습니다. 상단의 DBML 버튼을 클릭하여 다운로드할 수 있습니다. +- [FE] 📚 **Data Dictionary Export**: ERD 테이블/컬럼 메타데이터를 CSV 및 Markdown으로 내보내며, CSV formula injection과 Markdown 렌더링 escape를 적용했습니다. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f241e9cd..01e62762 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -54,6 +54,8 @@ import { downloadText, exportDDL, exportDiagramSvg, + exportDictionaryCsv, + exportDictionaryMarkdown, exportPlantUml, } from "./erd/export"; import { exportMermaid } from "./erd/mermaid"; @@ -620,6 +622,22 @@ export default function App() { downloadText("pg-erd-diagram.mermaid", exportMermaid(nodes, edges), "text/plain"); } + function onExportDictionaryCsv() { + downloadText( + "data_dictionary.csv", + exportDictionaryCsv(nodes, edges), + "text/csv;charset=utf-8", + ); + } + + function onExportDictionaryMarkdown() { + downloadText( + "data_dictionary.md", + exportDictionaryMarkdown(nodes, edges), + "text/markdown;charset=utf-8", + ); + } + function onDownloadDbml() { downloadText("pg-erd-diagram.dbml", exportDbml(nodes, edges), "text/plain"); } @@ -1580,6 +1598,7 @@ export default function App() { isOpen={isExportModalOpen} isCopied={isCopied} hasDdlExport={nodes.length > 0} + hasDictionaryExport={nodes.length > 0} hasDiagramExport={nodes.length > 0} shareLinkUrl={shareLinkUrl} isCreatingShareLink={isCreatingShareLink} @@ -1591,6 +1610,8 @@ export default function App() { onDownloadSvg={onDownloadSvg} onDownloadUml={onDownloadUml} onDownloadMermaid={onDownloadMermaid} + onExportDictionaryCsv={onExportDictionaryCsv} + onExportDictionaryMarkdown={onExportDictionaryMarkdown} onCreateShareLink={onCreateShareLink} onCopyShareLink={onCopyShareLink} /> diff --git a/frontend/src/components/modals/ExportModal.test.tsx b/frontend/src/components/modals/ExportModal.test.tsx index 36fbd1f7..5e101d71 100644 --- a/frontend/src/components/modals/ExportModal.test.tsx +++ b/frontend/src/components/modals/ExportModal.test.tsx @@ -8,6 +8,7 @@ const baseProps = { isOpen: true, isCopied: false, hasDdlExport: true, + hasDictionaryExport: true, hasDiagramExport: true, shareLinkUrl: '', isCreatingShareLink: false, @@ -19,6 +20,8 @@ const baseProps = { onDownloadSvg: vi.fn(), onDownloadUml: vi.fn(), onDownloadMermaid: vi.fn(), + onExportDictionaryCsv: vi.fn(), + onExportDictionaryMarkdown: vi.fn(), onCreateShareLink: vi.fn(), onCopyShareLink: vi.fn(), }; @@ -46,6 +49,8 @@ describe('ExportModal', () => { expect(screen.getByText('SVG 이미지')).toBeInTheDocument(); expect(screen.getByText('PlantUML')).toBeInTheDocument(); expect(screen.getByText('Mermaid')).toBeInTheDocument(); + expect(screen.getByText('Data Dictionary CSV')).toBeInTheDocument(); + expect(screen.getByText('Data Dictionary MD')).toBeInTheDocument(); }); it('copies an already generated share link', () => { @@ -71,6 +76,8 @@ describe('ExportModal', () => { const onDownloadSvg = vi.fn(); const onDownloadUml = vi.fn(); const onDownloadMermaid = vi.fn(); + const onExportDictionaryCsv = vi.fn(); + const onExportDictionaryMarkdown = vi.fn(); render( { onDownloadSvg={onDownloadSvg} onDownloadUml={onDownloadUml} onDownloadMermaid={onDownloadMermaid} + onExportDictionaryCsv={onExportDictionaryCsv} + onExportDictionaryMarkdown={onExportDictionaryMarkdown} />, ); @@ -86,11 +95,15 @@ describe('ExportModal', () => { fireEvent.click(screen.getByRole('button', { name: 'SVG 이미지 내보내기' })); fireEvent.click(screen.getByRole('button', { name: 'PlantUML 내보내기' })); fireEvent.click(screen.getByRole('button', { name: 'Mermaid 내보내기' })); + fireEvent.click(screen.getByRole('button', { name: '데이터 사전 CSV 내보내기' })); + fireEvent.click(screen.getByRole('button', { name: '데이터 사전 Markdown 내보내기' })); expect(onCopyExportDdl).toHaveBeenCalledOnce(); expect(onDownloadSvg).toHaveBeenCalledOnce(); expect(onDownloadUml).toHaveBeenCalledOnce(); expect(onDownloadMermaid).toHaveBeenCalledOnce(); + expect(onExportDictionaryCsv).toHaveBeenCalledOnce(); + expect(onExportDictionaryMarkdown).toHaveBeenCalledOnce(); }); it('shows share link copy or creation errors', () => { @@ -109,15 +122,18 @@ describe('ExportModal', () => { , ); - expect(screen.getAllByText('먼저 테이블을 추가하세요')).toHaveLength(4); + expect(screen.getAllByText('먼저 테이블을 추가하세요')).toHaveLength(6); expect(screen.getByRole('button', { name: 'SQL DDL 복사' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'SVG 이미지 내보내기' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'PlantUML 내보내기' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Mermaid 내보내기' })).toBeDisabled(); + expect(screen.getByRole('button', { name: '데이터 사전 CSV 내보내기' })).toBeDisabled(); + expect(screen.getByRole('button', { name: '데이터 사전 Markdown 내보내기' })).toBeDisabled(); }); it('exposes access-control guidance for disabled button', () => { diff --git a/frontend/src/components/modals/ExportModal.tsx b/frontend/src/components/modals/ExportModal.tsx index 1d4370a0..9feebd03 100644 --- a/frontend/src/components/modals/ExportModal.tsx +++ b/frontend/src/components/modals/ExportModal.tsx @@ -5,6 +5,7 @@ interface ExportModalProps { isOpen: boolean; isCopied: boolean; hasDdlExport: boolean; + hasDictionaryExport: boolean; hasDiagramExport: boolean; shareLinkUrl: string; isCreatingShareLink: boolean; @@ -16,6 +17,8 @@ interface ExportModalProps { onDownloadSvg: () => void; onDownloadUml: () => void; onDownloadMermaid: () => void; + onExportDictionaryCsv: () => void; + onExportDictionaryMarkdown: () => void; onCreateShareLink: () => void; onCopyShareLink: () => void; } @@ -33,6 +36,7 @@ export function ExportModal({ isOpen, isCopied, hasDdlExport, + hasDictionaryExport, hasDiagramExport, shareLinkUrl, isCreatingShareLink, @@ -44,6 +48,8 @@ export function ExportModal({ onDownloadSvg, onDownloadUml, onDownloadMermaid, + onExportDictionaryCsv, + onExportDictionaryMarkdown, onCreateShareLink, onCopyShareLink, }: ExportModalProps) { @@ -93,6 +99,22 @@ export function ExportModal({ onExport: onDownloadMermaid, ariaLabel: 'Mermaid 내보내기', }, + { + label: 'Data Dictionary CSV', + description: hasDictionaryExport ? '테이블/컬럼 목록' : '먼저 테이블을 추가하세요', + buttonLabel: '내보내기', + disabled: !hasDictionaryExport, + onExport: onExportDictionaryCsv, + ariaLabel: '데이터 사전 CSV 내보내기', + }, + { + label: 'Data Dictionary MD', + description: hasDictionaryExport ? '마크다운 문서' : '먼저 테이블을 추가하세요', + buttonLabel: '내보내기', + disabled: !hasDictionaryExport, + onExport: onExportDictionaryMarkdown, + ariaLabel: '데이터 사전 Markdown 내보내기', + }, ]; return ( diff --git a/frontend/src/erd/__tests__/exportDataDictionary.test.ts b/frontend/src/erd/__tests__/exportDataDictionary.test.ts new file mode 100644 index 00000000..85fa2737 --- /dev/null +++ b/frontend/src/erd/__tests__/exportDataDictionary.test.ts @@ -0,0 +1,187 @@ +import type { Edge, Node } from '@xyflow/react'; +import { describe, expect, it } from 'vitest'; + +import type { TableNodeData } from '../convert'; +import { exportDictionaryCsv, exportDictionaryMarkdown } from '../exportDataDictionary'; + +describe('exportDataDictionary', () => { + const nodes: Node[] = [ + { + id: 'users', + type: 'tableNode', + position: { x: 0, y: 0 }, + data: { + title: 'public.users', + comment: 'User accounts', + badges: { pk: true, fk: false }, + columns: [ + { + column_name: 'id', + data_type: 'integer', + is_pk: true, + is_not_null: true, + column_comment: 'Primary Key', + example_value: 1, + }, + { + column_name: 'account_id', + data_type: 'integer', + is_pk: false, + is_not_null: true, + column_comment: null, + example_value: 42, + }, + { + column_name: 'email', + data_type: 'varchar', + is_pk: false, + is_not_null: true, + column_comment: null, + example_value: 'test@example.com', + }, + ], + }, + }, + { + id: 'accounts', + type: 'tableNode', + position: { x: 0, y: 0 }, + data: { + title: 'public.accounts', + comment: null, + badges: { pk: true, fk: false }, + columns: [ + { + column_name: 'id', + data_type: 'integer', + is_pk: true, + is_not_null: true, + column_comment: null, + example_value: 1, + }, + ], + }, + }, + { + id: 'empty_table', + type: 'tableNode', + position: { x: 0, y: 0 }, + data: { + title: 'empty_table', + comment: null, + badges: { pk: false, fk: false }, + columns: [], + }, + }, + ]; + + const edges: Edge[] = [ + { + id: 'fk_users_accounts', + source: 'users', + target: 'accounts', + data: { sourceColumns: ['account_id'], targetColumns: ['id'] }, + }, + ]; + + it('exports table and column metadata to CSV', () => { + const csv = exportDictionaryCsv(nodes, edges); + + expect(csv).toContain('"Table Name","Table Comment","Column Name","Data Type","PK","FK","Not Null","Column Comment","Example Value"'); + expect(csv).toContain('"public.users","User accounts","id","integer","Y","N","Y","Primary Key","1"'); + expect(csv).toContain('"public.users","User accounts","account_id","integer","N","Y","Y","","42"'); + expect(csv).toContain('"public.users","User accounts","email","varchar","N","N","Y","","test@example.com"'); + expect(csv).toContain('"empty_table","","","","","","","",""'); + }); + + it('handles empty CSV exports', () => { + expect(exportDictionaryCsv([], edges)).toBe( + '"Table Name","Table Comment","Column Name","Data Type","PK","FK","Not Null","Column Comment","Example Value"', + ); + }); + + it('neutralizes CSV formula injection and normalizes control characters', () => { + const csv = exportDictionaryCsv( + [ + { + id: 'attack', + type: 'tableNode', + position: { x: 0, y: 0 }, + data: { + title: '=HYPERLINK("https://example.invalid")', + comment: '@note\r\nsecond line', + badges: { pk: false, fk: false }, + columns: [ + { + column_name: '+cmd', + data_type: '-integer', + is_pk: false, + is_not_null: false, + column_comment: '@comment', + example_value: '\t=2+2', + }, + ], + }, + }, + ], + [], + ); + + expect(csv).toContain('"\'=HYPERLINK(""https://example.invalid"")"'); + expect(csv).toContain('"\'@note second line"'); + expect(csv).toContain('"\'+cmd"'); + expect(csv).toContain('"\'-integer"'); + expect(csv).toContain('"\'@comment"'); + expect(csv).toContain('"\' =2+2"'); + }); + + it('exports table and column metadata to Markdown', () => { + const markdown = exportDictionaryMarkdown(nodes, edges); + + expect(markdown).toContain('# Data Dictionary'); + expect(markdown).toContain('## Table: public.users (User accounts)'); + expect(markdown).toContain('| id | integer | Y | N | Y | Primary Key | 1 |'); + expect(markdown).toContain('| account_id | integer | N | Y | Y | | 42 |'); + expect(markdown).toContain('## Table: empty_table'); + expect(markdown).toContain('No columns.'); + }); + + it('escapes Markdown table breakers and HTML-like content', () => { + const markdown = exportDictionaryMarkdown( + [ + { + id: 'danger', + type: 'tableNode', + position: { x: 0, y: 0 }, + data: { + title: 'orders|