Skip to content
Draft
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@
- [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를 적용했습니다.
- [FE] 💾 **TypeORM 및 SQLAlchemy Export**: ERD 다이어그램을 TypeORM 엔티티와 SQLAlchemy 모델 코드로 직접 내보낼 수 있는 기능을 추가했습니다. 공유 및 내보내기 모달에서 다운로드할 수 있습니다.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Frontend changelog entry missing

The new export actions update the root changelog only. Repository guidance also requires recording user-visible changes in the frontend changelog.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

12 changes: 12 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ import { exportMermaid } from "./erd/mermaid";
import { inferRelationships } from "./erd/autoInfer";
import { exportDbml } from "./erd/dbml";
import { exportPrisma } from "./erd/prisma";
import { exportTypeOrm } from "./erd/typeorm";
import { exportSqlAlchemy } from "./erd/sqlalchemy";
import { GRID_COLUMNS, GRID_X_GAP, GRID_Y_GAP } from "./erd/layoutConstants";
import { findSearchMatchedNodeIds } from "./erd/search";
import type { Connection, Project, Snapshot, SnapshotDetail } from "./types";
Expand Down Expand Up @@ -667,6 +669,14 @@ export default function App() {
downloadText("pg-erd-diagram.prisma", exportPrisma(nodes, edges), "text/plain");
}

function onDownloadTypeOrm() {
downloadText("pg-erd-diagram.typeorm.ts", exportTypeOrm(nodes, edges), "text/plain");
}

function onDownloadSqlAlchemy() {
downloadText("pg-erd-diagram.sqlalchemy.py", exportSqlAlchemy(nodes, edges), "text/plain");
}

function onExportDictionaryCsv() {
downloadText(
"data_dictionary.csv",
Expand Down Expand Up @@ -1651,6 +1661,8 @@ export default function App() {
onExportDictionaryMarkdown={onExportDictionaryMarkdown}
onDownloadDbml={onDownloadDbml}
onDownloadPrisma={onDownloadPrisma}
onDownloadTypeOrm={onDownloadTypeOrm}
onDownloadSqlAlchemy={onDownloadSqlAlchemy}
onCreateShareLink={onCreateShareLink}
onCopyShareLink={onCopyShareLink}
/>
Expand Down
18 changes: 17 additions & 1 deletion frontend/src/components/modals/ExportModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ const baseProps = {
onExportDictionaryMarkdown: vi.fn(),
onDownloadDbml: vi.fn(),
onDownloadPrisma: vi.fn(),
onDownloadTypeOrm: vi.fn(),
onDownloadSqlAlchemy: vi.fn(),
onCreateShareLink: vi.fn(),
onCopyShareLink: vi.fn(),
};
Expand Down Expand Up @@ -53,6 +55,8 @@ describe('ExportModal', () => {
expect(screen.getByText('Mermaid')).toBeInTheDocument();
expect(screen.getByText('DBML')).toBeInTheDocument();
expect(screen.getByText('Prisma Schema')).toBeInTheDocument();
expect(screen.getByText('TypeORM Entities')).toBeInTheDocument();
expect(screen.getByText('SQLAlchemy Models')).toBeInTheDocument();
expect(screen.getByText('Data Dictionary CSV')).toBeInTheDocument();
expect(screen.getByText('Data Dictionary MD')).toBeInTheDocument();
});
Expand Down Expand Up @@ -105,6 +109,8 @@ describe('ExportModal', () => {
const onExportDictionaryMarkdown = vi.fn();
const onDownloadDbml = vi.fn();
const onDownloadPrisma = vi.fn();
const onDownloadTypeOrm = vi.fn();
const onDownloadSqlAlchemy = vi.fn();

render(
<ExportModal
Expand All @@ -117,6 +123,10 @@ describe('ExportModal', () => {
onExportDictionaryMarkdown={onExportDictionaryMarkdown}
onDownloadDbml={onDownloadDbml}
onDownloadPrisma={onDownloadPrisma}
onDownloadTypeOrm={onDownloadTypeOrm}
onDownloadSqlAlchemy={onDownloadSqlAlchemy}
onCreateShareLink={vi.fn()}
onCopyShareLink={vi.fn()}
/>,
);

Expand All @@ -126,6 +136,8 @@ describe('ExportModal', () => {
fireEvent.click(screen.getByRole('button', { name: 'Mermaid 내보내기' }));
fireEvent.click(screen.getByRole('button', { name: 'DBML 내보내기' }));
fireEvent.click(screen.getByRole('button', { name: 'Prisma Schema 내보내기' }));
fireEvent.click(screen.getByRole('button', { name: 'TypeORM Entities 내보내기' }));
fireEvent.click(screen.getByRole('button', { name: 'SQLAlchemy Models 내보내기' }));
fireEvent.click(screen.getByRole('button', { name: '데이터 사전 CSV 내보내기' }));
fireEvent.click(screen.getByRole('button', { name: '데이터 사전 Markdown 내보내기' }));

Expand All @@ -135,6 +147,8 @@ describe('ExportModal', () => {
expect(onDownloadMermaid).toHaveBeenCalledOnce();
expect(onDownloadDbml).toHaveBeenCalledOnce();
expect(onDownloadPrisma).toHaveBeenCalledOnce();
expect(onDownloadTypeOrm).toHaveBeenCalledOnce();
expect(onDownloadSqlAlchemy).toHaveBeenCalledOnce();
expect(onExportDictionaryCsv).toHaveBeenCalledOnce();
expect(onExportDictionaryMarkdown).toHaveBeenCalledOnce();
});
Expand All @@ -160,13 +174,15 @@ describe('ExportModal', () => {
/>,
);

expect(screen.getAllByText('먼저 테이블을 추가하세요')).toHaveLength(8);
expect(screen.getAllByText('먼저 테이블을 추가하세요')).toHaveLength(10);
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: 'DBML 내보내기' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Prisma Schema 내보내기' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'TypeORM Entities 내보내기' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'SQLAlchemy Models 내보내기' })).toBeDisabled();
expect(screen.getByRole('button', { name: '데이터 사전 CSV 내보내기' })).toBeDisabled();
expect(screen.getByRole('button', { name: '데이터 사전 Markdown 내보내기' })).toBeDisabled();
});
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/components/modals/ExportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ interface ExportModalProps {
onExportDictionaryMarkdown: () => void;
onDownloadDbml: () => void;
onDownloadPrisma: () => void;
onDownloadTypeOrm: () => void;
onDownloadSqlAlchemy: () => void;
onCreateShareLink: () => void;
onCopyShareLink: () => void;
}
Expand Down Expand Up @@ -54,6 +56,8 @@ export function ExportModal({
onExportDictionaryMarkdown,
onDownloadDbml,
onDownloadPrisma,
onDownloadTypeOrm,
onDownloadSqlAlchemy,
onCreateShareLink,
onCopyShareLink,
}: ExportModalProps) {
Expand Down Expand Up @@ -119,6 +123,22 @@ export function ExportModal({
onExport: onDownloadPrisma,
ariaLabel: 'Prisma Schema 내보내기',
},
{
label: 'TypeORM Entities',
description: hasDiagramExport ? '텍스트 포맷' : '먼저 테이블을 추가하세요',
buttonLabel: '내보내기',
disabled: !hasDiagramExport,
onExport: onDownloadTypeOrm,
ariaLabel: 'TypeORM Entities 내보내기',
},
{
label: 'SQLAlchemy Models',
description: hasDiagramExport ? '텍스트 포맷' : '먼저 테이블을 추가하세요',
buttonLabel: '내보내기',
disabled: !hasDiagramExport,
onExport: onDownloadSqlAlchemy,
ariaLabel: 'SQLAlchemy Models 내보내기',
},
{
label: 'Data Dictionary CSV',
description: hasDictionaryExport ? '테이블/컬럼 목록' : '먼저 테이블을 추가하세요',
Expand Down
165 changes: 165 additions & 0 deletions frontend/src/erd/__tests__/orm-export-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { describe, expect, it } from 'vitest';

import { snapshotToGraph } from '../convert';
import { exportSqlAlchemy } from '../sqlalchemy';
import { exportTypeOrm } from '../typeorm';

type SnapshotInput = Parameters<typeof snapshotToGraph>[0];

function relationshipSnapshot(): SnapshotInput {
return {
relations: [
{
relation_oid: 1,
relation_kind: 'r',
schema_name: 'public',
relation_name: 'users',
},
{
relation_oid: 2,
relation_kind: 'r',
schema_name: 'public',
relation_name: 'posts',
},
],
columns: [
{
relation_oid: 1,
column_name: 'tenant_id',
data_type: 'uuid',
is_not_null: true,
},
{
relation_oid: 1,
column_name: 'id',
data_type: 'uuid',
is_not_null: true,
},
{
relation_oid: 2,
column_name: 'tenant_id',
data_type: 'uuid',
is_not_null: true,
},
{
relation_oid: 2,
column_name: 'user_id',
data_type: 'uuid',
is_not_null: true,
},
],
constraints: [],
pk_columns: [
{ relation_oid: 1, column_name: 'tenant_id' },
{ relation_oid: 1, column_name: 'id' },
],
fk_edges: [
{
fk_constraint_oid: 100,
fk_constraint_name: 'posts_user_fk',
child_relation_oid: 2,
parent_relation_oid: 1,
child_column_name: 'tenant_id',
parent_column_name: 'tenant_id',
column_ordinal: 1,
},
{
fk_constraint_oid: 100,
fk_constraint_name: 'posts_user_fk',
child_relation_oid: 2,
parent_relation_oid: 1,
child_column_name: 'user_id',
parent_column_name: 'id',
column_ordinal: 2,
},
],
};
}

describe('ORM export production graph contract', () => {
it('preserves schema separately from the relation name', () => {
const graph = snapshotToGraph(relationshipSnapshot());

const sqlalchemy = exportSqlAlchemy(graph.nodes, graph.edges);
const typeorm = exportTypeOrm(graph.nodes, graph.edges);

expect(sqlalchemy).toContain("__tablename__ = 'users'");

Check failure on line 86 in frontend/src/erd/__tests__/orm-export-contract.test.ts

View workflow job for this annotation

GitHub Actions / frontend

src/erd/__tests__/orm-export-contract.test.ts > ORM export production graph contract > preserves schema separately from the relation name

AssertionError: expected 'from __future__ import annotations\n\…' to contain '__tablename__ = \'users\'' - Expected + Received - __tablename__ = 'users' + from __future__ import annotations + + import datetime as dt + import uuid + from decimal import Decimal + + from sqlalchemy import ForeignKey + from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + class Base(DeclarativeBase): + pass + + class PublicUsers(Base): + __tablename__ = 'public.users' + + tenant_id: Mapped[uuid.UUID] = mapped_column(primary_key=True) + id: Mapped[uuid.UUID] = mapped_column(primary_key=True) + + class PublicPosts(Base): + __tablename__ = 'public.posts' + + tenant_id: Mapped[uuid.UUID] = mapped_column() + user_id: Mapped[uuid.UUID] = mapped_column() + ❯ src/erd/__tests__/orm-export-contract.test.ts:86:24
expect(sqlalchemy).toContain("__table_args__ = {'schema': 'public'}");
expect(sqlalchemy).not.toContain("__tablename__ = 'public.users'");

expect(typeorm).toContain("@Entity({ name: 'users', schema: 'public' })");
expect(typeorm).not.toContain("@Entity({ name: 'public.users' })");
});

it('exports every column pair of a composite FK produced by snapshotToGraph', () => {
const graph = snapshotToGraph(relationshipSnapshot());

const sqlalchemy = exportSqlAlchemy(graph.nodes, graph.edges);
const typeorm = exportTypeOrm(graph.nodes, graph.edges);

expect(sqlalchemy).toContain("ForeignKey('public.users.tenant_id')");

Check failure on line 100 in frontend/src/erd/__tests__/orm-export-contract.test.ts

View workflow job for this annotation

GitHub Actions / frontend

src/erd/__tests__/orm-export-contract.test.ts > ORM export production graph contract > exports every column pair of a composite FK produced by snapshotToGraph

AssertionError: expected 'from __future__ import annotations\n\…' to contain 'ForeignKey(\'public.users.tenant_id\')' - Expected + Received - ForeignKey('public.users.tenant_id') + from __future__ import annotations + + import datetime as dt + import uuid + from decimal import Decimal + + from sqlalchemy import ForeignKey + from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + class Base(DeclarativeBase): + pass + + class PublicUsers(Base): + __tablename__ = 'public.users' + + tenant_id: Mapped[uuid.UUID] = mapped_column(primary_key=True) + id: Mapped[uuid.UUID] = mapped_column(primary_key=True) + + class PublicPosts(Base): + __tablename__ = 'public.posts' + + tenant_id: Mapped[uuid.UUID] = mapped_column() + user_id: Mapped[uuid.UUID] = mapped_column() + ❯ src/erd/__tests__/orm-export-contract.test.ts:100:24
expect(sqlalchemy).toContain("ForeignKey('public.users.id')");
expect(typeorm).toContain("name: 'tenant_id'");
expect(typeorm).toContain("referencedColumnName: 'tenant_id'");
expect(typeorm).toContain("name: 'user_id'");
expect(typeorm).toContain("referencedColumnName: 'id'");
});

it('encodes database identifiers before placing them in generated code literals', () => {
const snapshot: SnapshotInput = {
relations: [
{
relation_oid: 1,
relation_kind: 'r',
schema_name: 'public',
relation_name: "orders'\n__import__('os').system('pwn')",
},
],
columns: [
{
relation_oid: 1,
column_name: "owner'\nconsole.log('pwn')",
data_type: 'text',
is_not_null: true,
},
],
constraints: [],
};
const graph = snapshotToGraph(snapshot);

const sqlalchemy = exportSqlAlchemy(graph.nodes, graph.edges);
const typeorm = exportTypeOrm(graph.nodes, graph.edges);

expect(sqlalchemy).not.toContain("\n__import__('os').system('pwn')");

Check failure on line 133 in frontend/src/erd/__tests__/orm-export-contract.test.ts

View workflow job for this annotation

GitHub Actions / frontend

src/erd/__tests__/orm-export-contract.test.ts > ORM export production graph contract > encodes database identifiers before placing them in generated code literals

AssertionError: expected 'from __future__ import annotations\n\…' not to contain '\n__import__(\'os\').system(\'pwn\')' - Expected + Received + from __future__ import annotations - __import__('os').system('pwn') + import datetime as dt + import uuid + from decimal import Decimal + + from sqlalchemy import ForeignKey + from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + class Base(DeclarativeBase): + pass + + class PublicOrdersImportOsSystemPwn(Base): + __tablename__ = 'public.orders' + __import__('os').system('pwn')' + + owner__console_log__pwn__: Mapped[str] = mapped_column('owner' + console.log('pwn')') + ❯ src/erd/__tests__/orm-export-contract.test.ts:133:28
expect(sqlalchemy).not.toContain("\nconsole.log('pwn')");
expect(typeorm).not.toContain("\n__import__('os').system('pwn')");
expect(typeorm).not.toContain("\nconsole.log('pwn')");
});

it('does not emit a Python keyword as a mapped attribute name', () => {
const snapshot: SnapshotInput = {
relations: [
{
relation_oid: 1,
relation_kind: 'r',
schema_name: 'public',
relation_name: 'keywords',
},
],
columns: [
{
relation_oid: 1,
column_name: 'class',
data_type: 'text',
is_not_null: true,
},
],
constraints: [],
};
const graph = snapshotToGraph(snapshot);
const sqlalchemy = exportSqlAlchemy(graph.nodes, graph.edges);

expect(sqlalchemy).not.toContain(' class: Mapped[');

Check failure on line 162 in frontend/src/erd/__tests__/orm-export-contract.test.ts

View workflow job for this annotation

GitHub Actions / frontend

src/erd/__tests__/orm-export-contract.test.ts > ORM export production graph contract > does not emit a Python keyword as a mapped attribute name

AssertionError: expected 'from __future__ import annotations\n\…' not to contain ' class: Mapped[' - Expected + Received - class: Mapped[ + from __future__ import annotations + + import datetime as dt + import uuid + from decimal import Decimal + + from sqlalchemy import ForeignKey + from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + class Base(DeclarativeBase): + pass + + class PublicKeywords(Base): + __tablename__ = 'public.keywords' + + class: Mapped[str] = mapped_column() + ❯ src/erd/__tests__/orm-export-contract.test.ts:162:28
expect(sqlalchemy).toContain("mapped_column('class'");
});
});
Loading
Loading