Skip to content
Merged
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
6 changes: 6 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -1611,6 +1616,7 @@ export default function App() {
onExportDictionaryCsv={onExportDictionaryCsv}
onExportDictionaryMarkdown={onExportDictionaryMarkdown}
onDownloadDbml={onDownloadDbml}
onDownloadPrisma={onDownloadPrisma}
onCreateShareLink={onCreateShareLink}
onCopyShareLink={onCopyShareLink}
/>
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/components/modals/ExportModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const baseProps = {
onExportDictionaryCsv: vi.fn(),
onExportDictionaryMarkdown: vi.fn(),
onDownloadDbml: vi.fn(),
onDownloadPrisma: vi.fn(),
onCreateShareLink: vi.fn(),
onCopyShareLink: vi.fn(),
};
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -102,6 +104,7 @@ describe('ExportModal', () => {
const onExportDictionaryCsv = vi.fn();
const onExportDictionaryMarkdown = vi.fn();
const onDownloadDbml = vi.fn();
const onDownloadPrisma = vi.fn();

render(
<ExportModal
Expand All @@ -113,6 +116,7 @@ describe('ExportModal', () => {
onExportDictionaryCsv={onExportDictionaryCsv}
onExportDictionaryMarkdown={onExportDictionaryMarkdown}
onDownloadDbml={onDownloadDbml}
onDownloadPrisma={onDownloadPrisma}
/>,
);

Expand All @@ -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 ๋‚ด๋ณด๋‚ด๊ธฐ' }));

Expand All @@ -129,6 +134,7 @@ describe('ExportModal', () => {
expect(onDownloadUml).toHaveBeenCalledOnce();
expect(onDownloadMermaid).toHaveBeenCalledOnce();
expect(onDownloadDbml).toHaveBeenCalledOnce();
expect(onDownloadPrisma).toHaveBeenCalledOnce();
expect(onExportDictionaryCsv).toHaveBeenCalledOnce();
expect(onExportDictionaryMarkdown).toHaveBeenCalledOnce();
});
Expand All @@ -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();
});
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/components/modals/ExportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ interface ExportModalProps {
onExportDictionaryCsv: () => void;
onExportDictionaryMarkdown: () => void;
onDownloadDbml: () => void;
onDownloadPrisma: () => void;
onCreateShareLink: () => void;
onCopyShareLink: () => void;
}
Expand Down Expand Up @@ -52,6 +53,7 @@ export function ExportModal({
onExportDictionaryCsv,
onExportDictionaryMarkdown,
onDownloadDbml,
onDownloadPrisma,
onCreateShareLink,
onCopyShareLink,
}: ExportModalProps) {
Expand Down Expand Up @@ -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 ? 'ํ…Œ์ด๋ธ”/์ปฌ๋Ÿผ ๋ชฉ๋ก' : '๋จผ์ € ํ…Œ์ด๋ธ”์„ ์ถ”๊ฐ€ํ•˜์„ธ์š”',
Expand Down
237 changes: 237 additions & 0 deletions frontend/src/erd/__tests__/prisma.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
import { describe, it, expect } from 'vitest';
import { exportPrisma } from '../prisma';
import type { Node, Edge } from '@xyflow/react';
import type { TableNodeData } from '../convert';

Comment on lines +1 to +5
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<TableNodeData>[] = [
{
id: '1',
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, is_pk: 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<TableNodeData>[] = [
{
id: '1',
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 },
],
},
},
{
id: '2',
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, is_pk: false },
],
},
},
];

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<TableNodeData>[] = [
{
id: '1',
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, 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 },
],
},
},
];

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<TableNodeData>[] = [
{
id: '1',
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_pk: false },
],
Comment on lines +123 to +126

},
},
];

const result = exportPrisma(nodes, []);
expect(result).toContain('email String @unique');
});

it('handles invalid prisma identifier names', () => {
const nodes: Node<TableNodeData>[] = [
{
id: '1',
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, is_pk: false },
],
},
},
];

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<TableNodeData>[] = [
{
id: '1',
position: { x: 0, y: 0 },
data: {
title: 'A',
badges: { pk: true, 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',
badges: { pk: true, fk: false },
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<TableNodeData>[] = [
{
id: '1',
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 },
],
},
},
{
id: '2',
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_pk: false },
],

},
},
];

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")');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
Loading
Loading