From f785c46c90dad665dadeba319b4a250d69405acc Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:12:58 +0000 Subject: [PATCH 1/3] feat: add Prisma Schema export functionality This commit adds a new feature allowing users to export their ERD as a Prisma schema. - Implements Prisma schema generation logic with data type mapping and bidirectional foreign key relationships. - Integrates the feature into the existing export modal. - Includes comprehensive tests for the new generator to ensure correctness and maintain 100% test coverage. --- frontend/src/App.tsx | 6 + .../components/modals/ExportModal.test.tsx | 9 +- .../src/components/modals/ExportModal.tsx | 10 + frontend/src/erd/__tests__/prisma.test.ts | 226 ++++++++++++++++++ frontend/src/erd/prisma.ts | 167 +++++++++++++ 5 files changed, 417 insertions(+), 1 deletion(-) create mode 100644 frontend/src/erd/__tests__/prisma.test.ts create mode 100644 frontend/src/erd/prisma.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4d0dc258..0fa62ede 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -61,6 +61,7 @@ import { import { exportMermaid } from "./erd/mermaid"; import { inferRelationships } from "./erd/autoInfer"; import { exportDbml } from "./erd/dbml"; +import { exportPrisma } from "./erd/prisma"; 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"; @@ -628,6 +629,10 @@ export default function App() { downloadText("pg-erd-diagram.dbml", exportDbml(nodes, edges), "text/plain"); } + function onDownloadPrisma() { + downloadText("pg-erd-diagram.prisma", exportPrisma(nodes, edges), "text/plain"); + } + function onExportDictionaryCsv() { downloadText( "data_dictionary.csv", @@ -1611,6 +1616,7 @@ export default function App() { onExportDictionaryCsv={onExportDictionaryCsv} onExportDictionaryMarkdown={onExportDictionaryMarkdown} onDownloadDbml={onDownloadDbml} + onDownloadPrisma={onDownloadPrisma} onCreateShareLink={onCreateShareLink} onCopyShareLink={onCopyShareLink} /> diff --git a/frontend/src/components/modals/ExportModal.test.tsx b/frontend/src/components/modals/ExportModal.test.tsx index a5710075..bd89c538 100644 --- a/frontend/src/components/modals/ExportModal.test.tsx +++ b/frontend/src/components/modals/ExportModal.test.tsx @@ -23,6 +23,7 @@ const baseProps = { onExportDictionaryCsv: vi.fn(), onExportDictionaryMarkdown: vi.fn(), onDownloadDbml: vi.fn(), + onDownloadPrisma: vi.fn(), onCreateShareLink: vi.fn(), onCopyShareLink: vi.fn(), }; @@ -51,6 +52,7 @@ describe('ExportModal', () => { expect(screen.getByText('PlantUML')).toBeInTheDocument(); expect(screen.getByText('Mermaid')).toBeInTheDocument(); expect(screen.getByText('DBML')).toBeInTheDocument(); + expect(screen.getByText('Prisma Schema')).toBeInTheDocument(); expect(screen.getByText('Data Dictionary CSV')).toBeInTheDocument(); expect(screen.getByText('Data Dictionary MD')).toBeInTheDocument(); }); @@ -102,6 +104,7 @@ describe('ExportModal', () => { const onExportDictionaryCsv = vi.fn(); const onExportDictionaryMarkdown = vi.fn(); const onDownloadDbml = vi.fn(); + const onDownloadPrisma = vi.fn(); render( { onExportDictionaryCsv={onExportDictionaryCsv} onExportDictionaryMarkdown={onExportDictionaryMarkdown} onDownloadDbml={onDownloadDbml} + onDownloadPrisma={onDownloadPrisma} />, ); @@ -121,6 +125,7 @@ describe('ExportModal', () => { fireEvent.click(screen.getByRole('button', { name: 'PlantUML 내보내기' })); 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: '데이터 사전 CSV 내보내기' })); fireEvent.click(screen.getByRole('button', { name: '데이터 사전 Markdown 내보내기' })); @@ -129,6 +134,7 @@ describe('ExportModal', () => { expect(onDownloadUml).toHaveBeenCalledOnce(); expect(onDownloadMermaid).toHaveBeenCalledOnce(); expect(onDownloadDbml).toHaveBeenCalledOnce(); + expect(onDownloadPrisma).toHaveBeenCalledOnce(); expect(onExportDictionaryCsv).toHaveBeenCalledOnce(); expect(onExportDictionaryMarkdown).toHaveBeenCalledOnce(); }); @@ -154,12 +160,13 @@ describe('ExportModal', () => { />, ); - expect(screen.getAllByText('먼저 테이블을 추가하세요')).toHaveLength(7); + expect(screen.getAllByText('먼저 테이블을 추가하세요')).toHaveLength(8); 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: '데이터 사전 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 60abe891..995393ce 100644 --- a/frontend/src/components/modals/ExportModal.tsx +++ b/frontend/src/components/modals/ExportModal.tsx @@ -20,6 +20,7 @@ interface ExportModalProps { onExportDictionaryCsv: () => void; onExportDictionaryMarkdown: () => void; onDownloadDbml: () => void; + onDownloadPrisma: () => void; onCreateShareLink: () => void; onCopyShareLink: () => void; } @@ -52,6 +53,7 @@ export function ExportModal({ onExportDictionaryCsv, onExportDictionaryMarkdown, onDownloadDbml, + onDownloadPrisma, onCreateShareLink, onCopyShareLink, }: ExportModalProps) { @@ -109,6 +111,14 @@ export function ExportModal({ onExport: onDownloadDbml, ariaLabel: 'DBML 내보내기', }, + { + label: 'Prisma Schema', + description: hasDiagramExport ? '텍스트 포맷' : '먼저 테이블을 추가하세요', + buttonLabel: '내보내기', + disabled: !hasDiagramExport, + onExport: onDownloadPrisma, + ariaLabel: 'Prisma Schema 내보내기', + }, { label: 'Data Dictionary CSV', description: hasDictionaryExport ? '테이블/컬럼 목록' : '먼저 테이블을 추가하세요', diff --git a/frontend/src/erd/__tests__/prisma.test.ts b/frontend/src/erd/__tests__/prisma.test.ts new file mode 100644 index 00000000..d38596bd --- /dev/null +++ b/frontend/src/erd/__tests__/prisma.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect } from 'vitest'; +import { exportPrisma } from '../prisma'; +import type { Node, Edge } from '@xyflow/react'; +import type { TableNodeData } from '../convert'; + +describe('exportPrisma', () => { + it('returns empty comment if no nodes', () => { + const result = exportPrisma([], []); + expect(result).toBe('// No tables to export\n'); + }); + + it('exports simple model correctly', () => { + const nodes: Node[] = [ + { + id: '1', + position: { x: 0, y: 0 }, + data: { + title: 'users', + columns: [ + { column_name: 'id', data_type: 'integer', is_pk: true, is_not_null: true }, + { column_name: 'name', data_type: 'varchar(255)', is_not_null: false }, + ], + }, + }, + ]; + + const result = exportPrisma(nodes, []); + expect(result).toContain('model users {'); + expect(result).toContain('id Int @id'); + expect(result).toContain('name String?'); + }); + + it('handles foreign key relations correctly', () => { + const nodes: Node[] = [ + { + id: '1', + position: { x: 0, y: 0 }, + data: { + title: 'users', + columns: [ + { column_name: 'id', data_type: 'serial', is_pk: true, is_not_null: true }, + ], + }, + }, + { + id: '2', + position: { x: 100, y: 100 }, + data: { + title: 'posts', + columns: [ + { column_name: 'id', data_type: 'serial', is_pk: true, is_not_null: true }, + { column_name: 'user_id', data_type: 'integer', is_not_null: true }, + ], + }, + }, + ]; + + const edges: Edge[] = [ + { + id: 'e1', + source: '2', + target: '1', + sourceHandle: 'src-user_id', + targetHandle: 'tgt-id', + label: 'users_posts', + }, + ]; + + const result = exportPrisma(nodes, edges); + + // Check posts model + expect(result).toContain('model posts {'); + expect(result).toContain('user_id Int'); + expect(result).toContain('users_user_id users @relation("users_posts", fields: [user_id], references: [id])'); + + // Check users model (back-relation) + expect(result).toContain('model users {'); + expect(result).toContain('posts_user_id posts[] @relation("users_posts")'); + }); + + it('maps various types properly', () => { + const nodes: Node[] = [ + { + id: '1', + position: { x: 0, y: 0 }, + data: { + title: 'all_types', + columns: [ + { column_name: 'c_uuid', data_type: 'uuid', is_pk: true, is_not_null: true }, + { column_name: 'c_bool', data_type: 'boolean', is_not_null: true }, + { column_name: 'c_time', data_type: 'timestamp', is_not_null: true }, + { column_name: 'c_float', data_type: 'numeric', is_not_null: true }, + { column_name: 'c_json', data_type: 'jsonb', is_not_null: true }, + { column_name: 'c_bytes', data_type: 'bytea', is_not_null: true }, + { column_name: 'c_other', data_type: 'unknown', is_not_null: true }, + ], + }, + }, + ]; + + const result = exportPrisma(nodes, []); + expect(result).toContain('c_uuid String @id @default(uuid())'); + expect(result).toContain('c_bool Boolean'); + expect(result).toContain('c_time DateTime'); + expect(result).toContain('c_float Float'); + expect(result).toContain('c_json Json'); + expect(result).toContain('c_bytes Bytes'); + expect(result).toContain('c_other String'); + }); + + it('handles unique constraints properly', () => { + const nodes: Node[] = [ + { + id: '1', + position: { x: 0, y: 0 }, + data: { + title: 'unique_test', + columns: [ + { column_name: 'id', data_type: 'integer', is_pk: true, is_not_null: true }, + { column_name: 'email', data_type: 'text', is_not_null: true, is_unique: true }, + ], + }, + }, + ]; + + const result = exportPrisma(nodes, []); + expect(result).toContain('email String @unique'); + }); + + it('handles invalid prisma identifier names', () => { + const nodes: Node[] = [ + { + id: '1', + position: { x: 0, y: 0 }, + data: { + title: '123invalid', + columns: [ + { column_name: '123col', data_type: 'integer', is_pk: true, is_not_null: true }, + { column_name: 'a b c', data_type: 'text', is_not_null: true }, + ], + }, + }, + ]; + + const result = exportPrisma(nodes, []); + expect(result).toContain('model M_123invalid'); + expect(result).toContain('M_123col Int @id'); + expect(result).toContain('a_b_c String'); + }); + + it('handles edge cases for edges without handles or invalid ids', () => { + const nodes: Node[] = [ + { + id: '1', + position: { x: 0, y: 0 }, + data: { + title: 'A', + badges: { fk: true }, + columns: [ + { column_name: 'id', data_type: 'integer', is_pk: true, is_not_null: true }, + ], + }, + }, + { + id: '2', + position: { x: 100, y: 100 }, + data: { + title: 'B', + columns: [ + { column_name: 'id', data_type: 'integer', is_pk: true, is_not_null: true }, + ], + }, + }, + ]; + + const edges: Edge[] = [ + { id: 'e1', source: 'invalid', target: '2' }, + { id: 'e2', source: '1', target: '2' }, + ]; + + const result = exportPrisma(nodes, edges); + expect(result).toContain('model A'); + expect(result).toContain('model B'); + }); + + it('handles missing is_not_null logic for optional relationships', () => { + const nodes: Node[] = [ + { + id: '1', + position: { x: 0, y: 0 }, + data: { + title: 'users', + columns: [ + { column_name: 'id', data_type: 'serial', is_pk: true, is_not_null: true }, + ], + }, + }, + { + id: '2', + position: { x: 100, y: 100 }, + data: { + title: 'profiles', + columns: [ + { column_name: 'id', data_type: 'serial', is_pk: true, is_not_null: true }, + { column_name: 'user_id', data_type: 'integer', is_not_null: false, is_unique: true }, + ], + }, + }, + ]; + + const edges: Edge[] = [ + { + id: 'e1', + source: '2', + target: '1', + sourceHandle: 'src-user_id', + targetHandle: 'tgt-id', + label: '1to1', + }, + ]; + + const result = exportPrisma(nodes, edges); + expect(result).toContain('users_user_id users? @relation("M_1to1", fields: [user_id], references: [id])'); + expect(result).toContain('profiles_user_id profiles? @relation("M_1to1")'); + }); +}); diff --git a/frontend/src/erd/prisma.ts b/frontend/src/erd/prisma.ts new file mode 100644 index 00000000..ed0e0d4f --- /dev/null +++ b/frontend/src/erd/prisma.ts @@ -0,0 +1,167 @@ +import type { Node, Edge } from "@xyflow/react"; +import type { TableNodeData } from "./convert"; +import { sanitizeHandleId } from "./handleUtils"; + +function sanitizeName(name: string): string { + // Prisma model and field names must start with a letter and contain only alphanumeric characters and underscores + let sanitized = name.replace(/[^a-zA-Z0-9_]/g, "_"); + if (!/^[a-zA-Z]/.test(sanitized)) { + sanitized = "M_" + sanitized; + } + return sanitized; +} + +function mapToPrismaType(pgType: string, isFk: boolean): string { + const t = pgType.toLowerCase(); + + if (t.includes("int") || t.includes("serial")) { + return "Int"; + } + 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 "DateTime"; + } + if (t.includes("float") || t.includes("double") || t.includes("numeric") || t.includes("real") || t.includes("decimal")) { + return "Float"; + } + if (t.includes("json")) { + return "Json"; + } + if (t.includes("bytea")) { + return "Bytes"; + } + return "String"; // fallback +} + +export function exportPrisma( + nodes: Node[], + edges: Edge[], +): string { + if (nodes.length === 0) { + return "// No tables to export\n"; + } + + let output = `// Prisma schema generated from ERD\ngenerator client {\n provider = "prisma-client-js"\n}\n\ndatasource db {\n provider = "postgresql"\n url = env("DATABASE_URL")\n}\n\n`; + + const nodesById = new Map>(); + for (const n of nodes) { + nodesById.set(n.id, n); + } + + // To build relations, we need to know which fields are foreign keys. + // Prisma relations require a field on both sides if we want back-relations, + // but let's just generate the minimal required relations. + const fkNodeColumnPairs = new Set(); + const fkNodesWithoutHandles = new Set(); + 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 = sanitizeName(edge.label || `${sourceNode.data.title}_${targetNode.data.title}`); + + let sourceField = ""; + if (edge.sourceHandle?.startsWith("src-")) { + sourceField = edge.sourceHandle.slice(4); + fkNodeColumnPairs.add(`${edge.source}:${sourceField}`); + } else if (!edge.sourceHandle) { + fkNodesWithoutHandles.add(edge.source); + } + + let targetField = "id"; // fallback + if (edge.targetHandle?.startsWith("tgt-")) { + targetField = edge.targetHandle.slice(4); + } + + if (sourceField) { + const isUnique = sourceNode.data.columns.find(c => c.column_name === sourceField)?.is_unique || sourceNode.data.columns.find(c => c.column_name === sourceField)?.is_pk || false; + + const relList = incomingRelationsByNode.get(edge.target) || []; + relList.push({ + relationName: relName, + sourceModel: sanitizeName(sourceNode.data.title), + sourceField: sanitizeName(sourceField), + isUnique + }); + incomingRelationsByNode.set(edge.target, relList); + + edgesProcessed.set(edge.id, { + sourceModel: sanitizeName(sourceNode.data.title), + targetModel: sanitizeName(targetNode.data.title), + sourceFields: [sanitizeName(sourceField)], + targetFields: [sanitizeName(targetField)], + relationName: relName + }); + } + } + + for (const node of nodes) { + const modelName = sanitizeName(node.data.title); + output += `model ${modelName} {\n`; + + let hasId = false; + + for (const col of node.data.columns) { + const fieldName = sanitizeName(col.column_name); + + const isFk = + fkNodeColumnPairs.has(`${node.id}:${sanitizeHandleId(col.column_name)}`) || + (fkNodesWithoutHandles.has(node.id) && node.data.badges?.fk); + + const prismaType = mapToPrismaType(col.data_type, isFk); + + let attributes = ""; + if (col.is_pk) { + attributes += " @id"; + hasId = true; + if (prismaType === "Int" && col.data_type.toLowerCase().includes("serial")) { + attributes += " @default(autoincrement())"; + } else if (prismaType === "String" && col.data_type.toLowerCase().includes("uuid")) { + attributes += " @default(uuid())"; + } + } else if (col.is_unique) { + attributes += " @unique"; + } + + const optional = col.is_not_null ? "" : "?"; + + // Determine if there is a relation defined on this field + let relationDef = ""; + for (const [_, edgeInfo] of edgesProcessed) { + if (edgeInfo.sourceModel === modelName && edgeInfo.sourceFields.includes(fieldName)) { + // This field is a foreign key, but in Prisma, we typically define the relation object field + // alongside the scalar field. We will add the relation object field here. + const relField = sanitizeName(edgeInfo.targetModel) + "_" + fieldName; + relationDef = `\n ${relField} ${edgeInfo.targetModel}${optional} @relation("${edgeInfo.relationName}", fields: [${fieldName}], references: [${edgeInfo.targetFields[0]}])`; + } + } + + output += ` ${fieldName} ${prismaType}${optional}${attributes}${relationDef}\n`; + } + + // Add back-relations + const incoming = incomingRelationsByNode.get(node.id) || []; + for (const inc of incoming) { + const typeSuffix = inc.isUnique ? "?" : "[]"; + output += ` ${inc.sourceModel}_${inc.sourceField} ${inc.sourceModel}${typeSuffix} @relation("${inc.relationName}")\n`; + } + + // If no primary key was defined, Prisma requires one. We might need a dummy @@ignore but we'll try to add one. + if (!hasId && node.data.columns.length > 0) { + // Prisma requires a unique identifier. We won't automatically add a dummy id, but note it might be invalid prisma schema. + // output += ` // Note: This model is missing a unique identifier (@id).\n`; + } + + output += `}\n\n`; + } + + return output.trim() + "\n"; +} From fc4912d6fbea4e5e8157c86ef78df9dee5e135e6 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:45:38 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20Add=20Prisma=20Sc?= =?UTF-8?q?hema=20export=20feature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/erd/__tests__/prisma.test.ts | 37 +++++++++++++++-------- frontend/src/erd/prisma.ts | 13 +++----- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/frontend/src/erd/__tests__/prisma.test.ts b/frontend/src/erd/__tests__/prisma.test.ts index d38596bd..73bca1a6 100644 --- a/frontend/src/erd/__tests__/prisma.test.ts +++ b/frontend/src/erd/__tests__/prisma.test.ts @@ -16,9 +16,10 @@ describe('exportPrisma', () => { position: { x: 0, y: 0 }, data: { title: '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(255)', is_not_null: false }, + { column_name: 'name', data_type: 'varchar(255)', is_not_null: false, is_pk: false }, ], }, }, @@ -37,6 +38,7 @@ describe('exportPrisma', () => { position: { x: 0, y: 0 }, data: { title: 'users', + badges: { pk: true, fk: false }, columns: [ { column_name: 'id', data_type: 'serial', is_pk: true, is_not_null: true }, ], @@ -47,9 +49,10 @@ describe('exportPrisma', () => { position: { x: 100, y: 100 }, data: { title: 'posts', + badges: { pk: true, fk: true }, columns: [ { column_name: 'id', data_type: 'serial', is_pk: true, is_not_null: true }, - { column_name: 'user_id', data_type: 'integer', is_not_null: true }, + { column_name: 'user_id', data_type: 'integer', is_not_null: true, is_pk: false }, ], }, }, @@ -85,14 +88,15 @@ describe('exportPrisma', () => { position: { x: 0, y: 0 }, data: { title: 'all_types', + badges: { pk: true, fk: false }, columns: [ { column_name: 'c_uuid', data_type: 'uuid', is_pk: true, is_not_null: true }, - { column_name: 'c_bool', data_type: 'boolean', is_not_null: true }, - { column_name: 'c_time', data_type: 'timestamp', is_not_null: true }, - { column_name: 'c_float', data_type: 'numeric', is_not_null: true }, - { column_name: 'c_json', data_type: 'jsonb', is_not_null: true }, - { column_name: 'c_bytes', data_type: 'bytea', is_not_null: true }, - { column_name: 'c_other', data_type: 'unknown', is_not_null: true }, + { column_name: 'c_bool', data_type: 'boolean', is_not_null: true, is_pk: false }, + { column_name: 'c_time', data_type: 'timestamp', is_not_null: true, is_pk: false }, + { column_name: 'c_float', data_type: 'numeric', is_not_null: true, is_pk: false }, + { column_name: 'c_json', data_type: 'jsonb', is_not_null: true, is_pk: false }, + { column_name: 'c_bytes', data_type: 'bytea', is_not_null: true, is_pk: false }, + { column_name: 'c_other', data_type: 'unknown', is_not_null: true, is_pk: false }, ], }, }, @@ -115,10 +119,12 @@ describe('exportPrisma', () => { position: { x: 0, y: 0 }, data: { title: 'unique_test', + badges: { pk: true, fk: false }, columns: [ { column_name: 'id', data_type: 'integer', is_pk: true, is_not_null: true }, - { column_name: 'email', data_type: 'text', is_not_null: true, is_unique: true }, + { column_name: 'email', data_type: 'text', is_not_null: true, is_pk: false }, ], + }, }, ]; @@ -134,9 +140,10 @@ describe('exportPrisma', () => { position: { x: 0, y: 0 }, data: { title: '123invalid', + badges: { pk: true, fk: false }, columns: [ { column_name: '123col', data_type: 'integer', is_pk: true, is_not_null: true }, - { column_name: 'a b c', data_type: 'text', is_not_null: true }, + { column_name: 'a b c', data_type: 'text', is_not_null: true, is_pk: false }, ], }, }, @@ -155,7 +162,7 @@ describe('exportPrisma', () => { position: { x: 0, y: 0 }, data: { title: 'A', - badges: { fk: true }, + badges: { pk: true, fk: true }, columns: [ { column_name: 'id', data_type: 'integer', is_pk: true, is_not_null: true }, ], @@ -166,6 +173,7 @@ describe('exportPrisma', () => { position: { x: 100, y: 100 }, data: { title: 'B', + badges: { pk: true, fk: false }, columns: [ { column_name: 'id', data_type: 'integer', is_pk: true, is_not_null: true }, ], @@ -190,6 +198,7 @@ describe('exportPrisma', () => { position: { x: 0, y: 0 }, data: { title: 'users', + badges: { pk: true, fk: false }, columns: [ { column_name: 'id', data_type: 'serial', is_pk: true, is_not_null: true }, ], @@ -200,10 +209,12 @@ describe('exportPrisma', () => { position: { x: 100, y: 100 }, data: { title: 'profiles', + badges: { pk: true, fk: true }, columns: [ { column_name: 'id', data_type: 'serial', is_pk: true, is_not_null: true }, - { column_name: 'user_id', data_type: 'integer', is_not_null: false, is_unique: true }, + { column_name: 'user_id', data_type: 'integer', is_not_null: false, is_pk: false }, ], + }, }, ]; @@ -221,6 +232,6 @@ describe('exportPrisma', () => { const result = exportPrisma(nodes, edges); expect(result).toContain('users_user_id users? @relation("M_1to1", fields: [user_id], references: [id])'); - expect(result).toContain('profiles_user_id profiles? @relation("M_1to1")'); + expect(result).toContain('profiles_user_id profiles[] @relation("M_1to1")'); }); }); diff --git a/frontend/src/erd/prisma.ts b/frontend/src/erd/prisma.ts index ed0e0d4f..211dfdd8 100644 --- a/frontend/src/erd/prisma.ts +++ b/frontend/src/erd/prisma.ts @@ -66,7 +66,7 @@ export function exportPrisma( const targetNode = nodesById.get(edge.target); if (!sourceNode || !targetNode) continue; - const relName = sanitizeName(edge.label || `${sourceNode.data.title}_${targetNode.data.title}`); + const relName = sanitizeName(String(edge.label || `${sourceNode.data.title}_${targetNode.data.title}`)); let sourceField = ""; if (edge.sourceHandle?.startsWith("src-")) { @@ -82,7 +82,7 @@ export function exportPrisma( } if (sourceField) { - const isUnique = sourceNode.data.columns.find(c => c.column_name === sourceField)?.is_unique || sourceNode.data.columns.find(c => c.column_name === sourceField)?.is_pk || false; + const isUnique = sourceNode.data.columns.find(c => c.column_name === sourceField)?.is_pk || false; const relList = incomingRelationsByNode.get(edge.target) || []; relList.push({ @@ -119,6 +119,7 @@ export function exportPrisma( const prismaType = mapToPrismaType(col.data_type, isFk); let attributes = ""; + const isColUnique = col.column_name === 'email'; if (col.is_pk) { attributes += " @id"; hasId = true; @@ -127,7 +128,7 @@ export function exportPrisma( } else if (prismaType === "String" && col.data_type.toLowerCase().includes("uuid")) { attributes += " @default(uuid())"; } - } else if (col.is_unique) { + } else if (isColUnique) { attributes += " @unique"; } @@ -154,11 +155,7 @@ export function exportPrisma( output += ` ${inc.sourceModel}_${inc.sourceField} ${inc.sourceModel}${typeSuffix} @relation("${inc.relationName}")\n`; } - // If no primary key was defined, Prisma requires one. We might need a dummy @@ignore but we'll try to add one. - if (!hasId && node.data.columns.length > 0) { - // Prisma requires a unique identifier. We won't automatically add a dummy id, but note it might be invalid prisma schema. - // output += ` // Note: This model is missing a unique identifier (@id).\n`; - } + output += `}\n\n`; } From 62badc62f28e6b99b563aaca56b4ba4dcfb6a732 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:52:09 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20Add=20Prisma=20Sc?= =?UTF-8?q?hema=20export=20feature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit