diff --git a/CHANGELOG.md b/CHANGELOG.md index 35613431a..ec75158e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 모델 코드로 직접 내보낼 수 있는 기능을 추가했습니다. 공유 및 내보내기 모달에서 다운로드할 수 있습니다. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 49812e448..a9c458226 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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"; @@ -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", @@ -1651,6 +1661,8 @@ export default function App() { onExportDictionaryMarkdown={onExportDictionaryMarkdown} onDownloadDbml={onDownloadDbml} onDownloadPrisma={onDownloadPrisma} + onDownloadTypeOrm={onDownloadTypeOrm} + onDownloadSqlAlchemy={onDownloadSqlAlchemy} onCreateShareLink={onCreateShareLink} onCopyShareLink={onCopyShareLink} /> diff --git a/frontend/src/components/modals/ExportModal.test.tsx b/frontend/src/components/modals/ExportModal.test.tsx index bd89c5384..9eae2e5da 100644 --- a/frontend/src/components/modals/ExportModal.test.tsx +++ b/frontend/src/components/modals/ExportModal.test.tsx @@ -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(), }; @@ -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(); }); @@ -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( { onExportDictionaryMarkdown={onExportDictionaryMarkdown} onDownloadDbml={onDownloadDbml} onDownloadPrisma={onDownloadPrisma} + onDownloadTypeOrm={onDownloadTypeOrm} + onDownloadSqlAlchemy={onDownloadSqlAlchemy} + onCreateShareLink={vi.fn()} + onCopyShareLink={vi.fn()} />, ); @@ -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 내보내기' })); @@ -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(); }); @@ -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(); }); diff --git a/frontend/src/components/modals/ExportModal.tsx b/frontend/src/components/modals/ExportModal.tsx index 995393ceb..24fa5af91 100644 --- a/frontend/src/components/modals/ExportModal.tsx +++ b/frontend/src/components/modals/ExportModal.tsx @@ -21,6 +21,8 @@ interface ExportModalProps { onExportDictionaryMarkdown: () => void; onDownloadDbml: () => void; onDownloadPrisma: () => void; + onDownloadTypeOrm: () => void; + onDownloadSqlAlchemy: () => void; onCreateShareLink: () => void; onCopyShareLink: () => void; } @@ -54,6 +56,8 @@ export function ExportModal({ onExportDictionaryMarkdown, onDownloadDbml, onDownloadPrisma, + onDownloadTypeOrm, + onDownloadSqlAlchemy, onCreateShareLink, onCopyShareLink, }: ExportModalProps) { @@ -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 ? '테이블/컬럼 목록' : '먼저 테이블을 추가하세요', diff --git a/frontend/src/erd/__tests__/orm-export-contract.test.ts b/frontend/src/erd/__tests__/orm-export-contract.test.ts new file mode 100644 index 000000000..8dfbf814e --- /dev/null +++ b/frontend/src/erd/__tests__/orm-export-contract.test.ts @@ -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[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'"); + 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')"); + 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')"); + 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['); + expect(sqlalchemy).toContain("mapped_column('class'"); + }); +}); diff --git a/frontend/src/erd/__tests__/sqlalchemy.test.ts b/frontend/src/erd/__tests__/sqlalchemy.test.ts new file mode 100644 index 000000000..5e509eaf3 --- /dev/null +++ b/frontend/src/erd/__tests__/sqlalchemy.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from 'vitest'; +import { exportSqlAlchemy } from '../sqlalchemy'; +import type { Node, Edge } from '@xyflow/react'; +import type { TableNodeData } from '../convert'; + +describe('exportSqlAlchemy', () => { + it('returns empty string when no tables', () => { + const result = exportSqlAlchemy([], []); + expect(result).toBe('# No tables to export\n'); + }); + + it('exports tables with no columns', () => { + const nodes: Node[] = [{ id: '1', position: { x: 0, y: 0 }, data: { title: 'empty', columns: [], badges: { pk: false, fk: false } } }]; + const result = exportSqlAlchemy(nodes, []); + expect(result).toContain('class Empty(Base):\n __tablename__ = \'empty\'\n\n pass'); + }); + + it('handles various data types', () => { + const nodes: Node[] = [{ + id: '1', position: { x: 0, y: 0 }, data: { title: 'types', columns: [ + { column_name: 't_json', data_type: 'json', is_pk: false, is_not_null: true }, + { column_name: 't_bytea', data_type: 'bytea', is_pk: false, is_not_null: true }, + { column_name: 't_unknown', data_type: 'unknown', is_pk: false, is_not_null: true }, + ], badges: { pk: false, fk: false } } + }]; + const result = exportSqlAlchemy(nodes, []); + expect(result).toContain('Mapped[dict | list]'); + expect(result).toContain('Mapped[bytes]'); + expect(result).toContain('Mapped[str]'); + }); + + it('handles edge case sanitize function formats', () => { + const nodes: Node[] = [{ + id: '1', position: { x: 0, y: 0 }, data: { title: '1invalidClass', columns: [ + { column_name: '1invalidField', data_type: 'int', is_pk: false, is_not_null: true }, + ], badges: { pk: false, fk: false } } + }]; + const result = exportSqlAlchemy(nodes, []); + expect(result).toContain('class Entity1invalidClass(Base):'); + expect(result).toContain('field_1invalidField: Mapped[int]'); + }); + + it('ignores edges missing nodes', () => { + const edges: Edge[] = [{ id: 'e1', source: 'none1', target: 'none2' }]; + const result = exportSqlAlchemy([], edges); + expect(result).toBe('# No tables to export\n'); + }); + + it('exports a single table with primary key and columns', () => { + const nodes: Node[] = [ + { + id: '1', + position: { x: 0, y: 0 }, + data: { + title: 'users', + columns: [ + { column_name: 'id', data_type: 'uuid', is_pk: true, is_not_null: true }, + { column_name: 'email', data_type: 'varchar', is_pk: false, is_not_null: true }, + { column_name: 'age', data_type: 'int', is_pk: false, is_not_null: false }, + ], + badges: { pk: false, fk: false }, + }, + }, + ]; + + const result = exportSqlAlchemy(nodes, []); + expect(result).toContain("class Users(Base):"); + expect(result).toContain("__tablename__ = 'users'"); + expect(result).toContain("id: Mapped[uuid.UUID] = mapped_column(primary_key=True)"); + expect(result).toContain("email: Mapped[str] = mapped_column()"); + expect(result).toContain("age: Mapped[int | None] = mapped_column()"); + }); + + it('exports relations', () => { + const nodes: Node[] = [ + { + id: '1', + position: { x: 0, y: 0 }, + data: { + title: 'users', + columns: [ + { column_name: 'id', data_type: 'uuid', is_pk: true, is_not_null: true }, + ], + badges: { pk: false, fk: false }, + }, + }, + { + id: '2', + position: { x: 0, y: 0 }, + data: { + title: 'posts', + columns: [ + { column_name: 'id', data_type: 'uuid', is_pk: true, is_not_null: true }, + { column_name: 'user_id', data_type: 'uuid', is_pk: false, is_not_null: true }, + ], + badges: { pk: false, fk: false }, + }, + }, + ]; + const edges: Edge[] = [ + { + id: 'e1', + source: '2', + target: '1', + sourceHandle: 'src-user_id', + targetHandle: 'tgt-id', + }, + ]; + + const result = exportSqlAlchemy(nodes, edges); + expect(result).toContain("user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('users.id'))"); + }); +}); diff --git a/frontend/src/erd/__tests__/typeorm.test.ts b/frontend/src/erd/__tests__/typeorm.test.ts new file mode 100644 index 000000000..9dfd09f7f --- /dev/null +++ b/frontend/src/erd/__tests__/typeorm.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest'; +import { exportTypeOrm } from '../typeorm'; +import type { Node, Edge } from '@xyflow/react'; +import type { TableNodeData } from '../convert'; + +describe('exportTypeOrm', () => { + it('returns empty string when no tables', () => { + const result = exportTypeOrm([], []); + expect(result).toBe('// No tables to export\n'); + }); + + it('exports tables with various types', () => { + const nodes: Node[] = [{ + id: '1', position: { x: 0, y: 0 }, data: { title: 'types', columns: [ + { column_name: 't_json', data_type: 'json', is_pk: false, is_not_null: true }, + { column_name: 't_bytea', data_type: 'bytea', is_pk: false, is_not_null: true }, + { column_name: 't_unknown', data_type: 'unknown', is_pk: false, is_not_null: true }, + { column_name: 't_serial', data_type: 'serial', is_pk: true, is_not_null: true }, + { column_name: 't_time', data_type: 'time', is_pk: false, is_not_null: true }, + { column_name: 't_bool', data_type: 'bool', is_pk: false, is_not_null: true }, + ], badges: { pk: false, fk: false } } + }]; + const result = exportTypeOrm(nodes, []); + expect(result).toContain('t_json!: any;'); + expect(result).toContain('t_bytea!: Buffer;'); + expect(result).toContain('t_unknown!: string;'); + expect(result).toContain('@PrimaryGeneratedColumn()'); + expect(result).toContain('t_time!: Date;'); + expect(result).toContain('t_bool!: boolean;'); + }); + + it('handles edge case sanitize function formats', () => { + const nodes: Node[] = [{ + id: '1', position: { x: 0, y: 0 }, data: { title: '1invalidClass', columns: [ + { column_name: '1invalidField', data_type: 'int', is_pk: false, is_not_null: true }, + ], badges: { pk: false, fk: false } } + }]; + const result = exportTypeOrm(nodes, []); + expect(result).toContain('class Entity1invalidClass {'); + expect(result).toContain('field_1invalidField!: number;'); + }); + + it('ignores edges missing nodes', () => { + const edges: Edge[] = [{ id: 'e1', source: 'none1', target: 'none2' }]; + const result = exportTypeOrm([], edges); + expect(result).toBe('// No tables to export\n'); + }); + + it('exports a single table with primary key and columns', () => { + const nodes: Node[] = [ + { + id: '1', + position: { x: 0, y: 0 }, + data: { + title: 'users', + columns: [ + { column_name: 'id', data_type: 'uuid', is_pk: true, is_not_null: true }, + { column_name: 'email', data_type: 'varchar', is_pk: false, is_not_null: true }, + { column_name: 'age', data_type: 'int', is_pk: false, is_not_null: false }, + ], + badges: { pk: false, fk: false }, + }, + }, + ]; + + const result = exportTypeOrm(nodes, []); + expect(result).toContain("import { Entity, PrimaryColumn, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm';"); + expect(result).toContain("@Entity({ name: 'users' })"); + expect(result).toContain("export class Users {"); + expect(result).toContain("@PrimaryColumn()"); + expect(result).toContain("id!: string;"); + expect(result).toContain("@Column()"); + expect(result).toContain("email!: string;"); + expect(result).toContain("@Column({ nullable: true })"); + expect(result).toContain("age?: number | null;"); + }); + + it('exports relations', () => { + const nodes: Node[] = [ + { + id: '1', + position: { x: 0, y: 0 }, + data: { + title: 'users', + columns: [ + { column_name: 'id', data_type: 'uuid', is_pk: true, is_not_null: true }, + ], + badges: { pk: false, fk: false }, + }, + }, + { + id: '2', + position: { x: 0, y: 0 }, + data: { + title: 'posts', + columns: [ + { column_name: 'id', data_type: 'uuid', is_pk: true, is_not_null: true }, + { column_name: 'user_id', data_type: 'uuid', is_pk: false, is_not_null: true }, + ], + badges: { pk: false, fk: false }, + }, + }, + ]; + const edges: Edge[] = [ + { + id: 'e1', + source: '2', + target: '1', + sourceHandle: 'src-user_id', + targetHandle: 'tgt-id', + }, + ]; + + const result = exportTypeOrm(nodes, edges); + expect(result).toContain("@ManyToOne(() => Users)"); + expect(result).toContain("@JoinColumn({ name: 'user_id', referencedColumnName: 'id' })"); + expect(result).toContain("Users_user_id?: Users;"); + + // Check one to many back relation + expect(result).toContain("@OneToMany(() => Posts, (child) => child.Users_user_id)"); + expect(result).toContain("posts?: Posts[];"); + }); +}); diff --git a/frontend/src/erd/sqlalchemy.ts b/frontend/src/erd/sqlalchemy.ts new file mode 100644 index 000000000..9ec703d7a --- /dev/null +++ b/frontend/src/erd/sqlalchemy.ts @@ -0,0 +1,128 @@ +import type { Node, Edge } from "@xyflow/react"; +import type { TableNodeData } from "./convert"; + +function sanitizeClassName(name: string): string { + let sanitized = name.replace(/[^a-zA-Z0-9_]/g, "_"); + if (!/^[a-zA-Z]/.test(sanitized)) { + sanitized = "Entity_" + sanitized; + } + return sanitized.split('_').map(part => part.charAt(0).toUpperCase() + part.slice(1)).join(''); +} + +function sanitizeFieldName(name: string): string { + let sanitized = name.replace(/[^a-zA-Z0-9_]/g, "_"); + if (!/^[a-zA-Z]/.test(sanitized)) { + sanitized = "field_" + sanitized; + } + return sanitized; +} + +function mapToPyType(pgType: string): string { + const t = pgType.toLowerCase(); + if (t.includes("int") || t.includes("serial")) return "int"; + if (t.includes("float") || t.includes("double") || t.includes("numeric") || t.includes("real") || t.includes("decimal")) return "Decimal"; + if (t.includes("char") || t.includes("text")) return "str"; + if (t.includes("uuid")) return "uuid.UUID"; + if (t.includes("bool")) return "bool"; + if (t.includes("time") || t.includes("date")) return "dt.datetime"; + if (t.includes("json")) return "dict | list"; + if (t.includes("bytea")) return "bytes"; + return "str"; +} + +export function exportSqlAlchemy( + nodes: Node[], + edges: Edge[], +): string { + if (nodes.length === 0) { + return "# No tables to export\n"; + } + + let output = `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 + +`; + + const nodesById = new Map>(); + for (const n of nodes) { + nodesById.set(n.id, n); + } + + const edgesProcessed = new Map(); + + for (const edge of edges) { + const sourceNode = nodesById.get(edge.source); + const targetNode = nodesById.get(edge.target); + if (!sourceNode || !targetNode) continue; + + let sourceField = ""; + if (edge.sourceHandle?.startsWith("src-")) { + sourceField = edge.sourceHandle.slice(4); + } + + let targetField = "id"; // fallback + if (edge.targetHandle?.startsWith("tgt-")) { + targetField = edge.targetHandle.slice(4); + } + + if (sourceField) { + edgesProcessed.set(edge.id, { + sourceModel: sanitizeClassName(sourceNode.data.title), + targetModel: sanitizeClassName(targetNode.data.title), + sourceFields: [sourceField], + targetFields: [targetField], + targetTableName: targetNode.data.title + }); + } + } + + for (const node of nodes) { + const modelName = sanitizeClassName(node.data.title); + output += `class ${modelName}(Base):\n`; + if (node.data.comment) { + output += ` """${node.data.comment.replace(/"""/g, "'''")}"""\n`; + } + output += ` __tablename__ = '${node.data.title}'\n\n`; + + if (node.data.columns.length === 0) { + output += ` pass\n\n`; + continue; + } + + for (const col of node.data.columns) { + const fieldName = sanitizeFieldName(col.column_name); + let pyType = mapToPyType(col.data_type); + + const isOptional = !col.is_not_null; + if (isOptional) pyType += " | None"; + + let args: string[] = []; + if (fieldName !== col.column_name) args.push(`'${col.column_name}'`); + + for (const edgeInfo of edgesProcessed.values()) { + if (edgeInfo.sourceModel === modelName && edgeInfo.sourceFields.includes(col.column_name)) { + args.push(`ForeignKey('${edgeInfo.targetTableName}.${edgeInfo.targetFields[0]}')`); + } + } + + if (col.is_pk) args.push("primary_key=True"); + + const mappedColumnCall = args.length > 0 ? `mapped_column(${args.join(", ")})` : `mapped_column()`; + + output += ` ${fieldName}: Mapped[${pyType}] = ${mappedColumnCall}\n`; + } + + output += `\n`; + } + + return output.trim() + "\n"; +} diff --git a/frontend/src/erd/typeorm.ts b/frontend/src/erd/typeorm.ts new file mode 100644 index 000000000..9c4c9a9df --- /dev/null +++ b/frontend/src/erd/typeorm.ts @@ -0,0 +1,153 @@ +import type { Node, Edge } from "@xyflow/react"; +import type { TableNodeData } from "./convert"; + +function sanitizeClassName(name: string): string { + let sanitized = name.replace(/[^a-zA-Z0-9_]/g, "_"); + if (!/^[a-zA-Z]/.test(sanitized)) { + sanitized = "Entity_" + sanitized; + } + // Convert to PascalCase + return sanitized.split('_').map(part => part.charAt(0).toUpperCase() + part.slice(1)).join(''); +} + +function sanitizeFieldName(name: string): string { + let sanitized = name.replace(/[^a-zA-Z0-9_]/g, "_"); + if (!/^[a-zA-Z]/.test(sanitized)) { + sanitized = "field_" + sanitized; + } + return sanitized; +} + +function mapToTsType(pgType: string): string { + const t = pgType.toLowerCase(); + if (t.includes("int") || t.includes("serial") || t.includes("float") || t.includes("double") || t.includes("numeric") || t.includes("real") || t.includes("decimal")) { + return "number"; + } + if (t.includes("char") || t.includes("text") || t.includes("uuid")) { + return "string"; + } + if (t.includes("bool")) { + return "boolean"; + } + if (t.includes("time") || t.includes("date")) { + return "Date"; + } + if (t.includes("json")) { + return "any"; + } + if (t.includes("bytea")) { + return "Buffer"; + } + return "string"; // fallback +} + +export function exportTypeOrm( + nodes: Node[], + edges: Edge[], +): string { + if (nodes.length === 0) { + return "// No tables to export\n"; + } + + let output = `import { Entity, PrimaryColumn, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm';\n\n`; + + const nodesById = new Map>(); + for (const n of nodes) { + nodesById.set(n.id, n); + } + + const incomingRelationsByNode = new Map>(); + const edgesProcessed = new Map(); + + for (const edge of edges) { + const sourceNode = nodesById.get(edge.source); + const targetNode = nodesById.get(edge.target); + if (!sourceNode || !targetNode) continue; + + const relName = sanitizeFieldName(String(edge.label || `${sourceNode.data.title}_${targetNode.data.title}`)); + + let sourceField = ""; + if (edge.sourceHandle?.startsWith("src-")) { + sourceField = edge.sourceHandle.slice(4); + } + + let targetField = "id"; // fallback + if (edge.targetHandle?.startsWith("tgt-")) { + targetField = edge.targetHandle.slice(4); + } + + if (sourceField) { + const relList = incomingRelationsByNode.get(edge.target) || []; + relList.push({ + relationName: relName, + sourceModel: sanitizeClassName(sourceNode.data.title), + sourceField: sanitizeFieldName(sourceField), + }); + incomingRelationsByNode.set(edge.target, relList); + + edgesProcessed.set(edge.id, { + sourceModel: sanitizeClassName(sourceNode.data.title), + targetModel: sanitizeClassName(targetNode.data.title), + sourceFields: [sanitizeFieldName(sourceField)], + targetFields: [sanitizeFieldName(targetField)], + relationName: relName + }); + } + } + + for (const node of nodes) { + const modelName = sanitizeClassName(node.data.title); + output += `@Entity({ name: '${node.data.title}' })\n`; + output += `export class ${modelName} {\n`; + + for (const col of node.data.columns) { + const fieldName = sanitizeFieldName(col.column_name); + const tsType = mapToTsType(col.data_type); + + const isOptional = !col.is_not_null; + const tsNull = isOptional ? " | null" : ""; + const optionalFlag = isOptional && !col.is_pk ? "?" : "!"; + + let colOptions: string[] = []; + if (fieldName !== col.column_name) colOptions.push(`name: '${col.column_name}'`); + if (isOptional && !col.is_pk) colOptions.push(`nullable: true`); + + const optionsStr = colOptions.length > 0 ? `{ ${colOptions.join(', ')} }` : ''; + + if (col.is_pk) { + if (col.data_type.toLowerCase().includes("serial")) { + output += ` @PrimaryGeneratedColumn(${optionsStr})\n`; + } else { + output += ` @PrimaryColumn(${optionsStr})\n`; + } + } else { + output += ` @Column(${optionsStr})\n`; + } + + output += ` ${fieldName}${optionalFlag}: ${tsType}${tsNull};\n\n`; + + // ManyToOne relations + for (const edgeInfo of edgesProcessed.values()) { + if (edgeInfo.sourceModel === modelName && edgeInfo.sourceFields.includes(fieldName)) { + const relField = sanitizeFieldName(edgeInfo.targetModel) + "_" + fieldName; + output += ` @ManyToOne(() => ${edgeInfo.targetModel})\n`; + output += ` @JoinColumn({ name: '${col.column_name}', referencedColumnName: '${edgeInfo.targetFields[0]}' })\n`; + output += ` ${relField}?: ${edgeInfo.targetModel};\n\n`; + } + } + } + + // OneToMany back-relations + const incoming = incomingRelationsByNode.get(node.id) || []; + for (const inc of incoming) { + const relFieldBase = inc.sourceModel.charAt(0).toLowerCase() + inc.sourceModel.slice(1); + const relField = relFieldBase.endsWith("s") ? relFieldBase : relFieldBase + "s"; + output += ` @OneToMany(() => ${inc.sourceModel}, (child) => child.${modelName}_${inc.sourceField})\n`; + output += ` ${relField}?: ${inc.sourceModel}[];\n\n`; + } + + output += `}\n\n`; + } + + return output.trim() + "\n"; +}