Initial commit
This commit is contained in:
+44
@@ -0,0 +1,44 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
.pnpm-store
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
build
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
.turbo
|
||||||
|
.cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
!.vscode/settings.json
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Test
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
coverage
|
||||||
|
pnpm-lock.yaml
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100,
|
||||||
|
"bracketSpacing": true
|
||||||
|
}
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"prisma.prisma",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"bradlc.vscode-tailwindcss",
|
||||||
|
"vitest.explorer"
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+13
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "explicit"
|
||||||
|
},
|
||||||
|
"typescript.preferences.importModuleSpecifier": "relative",
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
|
"tailwindCSS.experimental.classRegex": [["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]],
|
||||||
|
"files.associations": {
|
||||||
|
"*.css": "tailwindcss"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Prisma UML Viewer
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
Visualize your Prisma schemas as interactive ERD diagrams.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Real-time schema parsing
|
||||||
|
- Interactive drag & drop diagrams with zoom and minimap
|
||||||
|
- Compatible with Prisma 5, 6 and 7
|
||||||
|
- Export to Mermaid, PlantUML, DBML
|
||||||
|
- Auto-layout with dagre
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Framework:** Next.js 16 (App Router)
|
||||||
|
- **UI:** React 19, Tailwind CSS 4
|
||||||
|
- **Diagrams:** ReactFlow
|
||||||
|
- **Monorepo:** pnpm + Turborepo
|
||||||
|
|
||||||
|
## Packages
|
||||||
|
|
||||||
|
- [`@prisma-uml/parser`](./packages/prisma-parser/) - Schema parsing and export
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:3000
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
pnpm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Author
|
||||||
|
|
||||||
|
[Jessy DAVID](https://jessy-david.dev)
|
||||||
|
|
||||||
|
## Acknowledgements
|
||||||
|
|
||||||
|
- [prisma-generate-uml](https://github.com/AbianS/prisma-generate-uml) - Inspiration for this project
|
||||||
|
- [Prisma](https://www.prisma.io/) - Next-generation ORM
|
||||||
|
- [ReactFlow](https://reactflow.dev/) - Interactive node-based diagrams
|
||||||
|
- [Next.js](https://nextjs.org/) - React framework
|
||||||
|
- [Tailwind CSS](https://tailwindcss.com/) - Utility-first CSS
|
||||||
|
- [dagre](https://github.com/dagrejs/dagre) - Graph layout algorithm
|
||||||
|
- [Monaco Editor](https://microsoft.github.io/monaco-editor/) - Code editor
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { FlatCompat } from '@eslint/eslintrc';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: __dirname,
|
||||||
|
});
|
||||||
|
|
||||||
|
const eslintConfig = [...compat.extends('next/core-web-vitals', 'next/typescript')];
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
transpilePackages: ['@prisma-uml/parser'],
|
||||||
|
output: 'standalone',
|
||||||
|
experimental: {
|
||||||
|
optimizePackageImports: ['@heroicons/react'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "@prisma-uml/web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Jessy DAVID - https://jessy-david.dev",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --turbopack",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dagrejs/dagre": "^1.1.8",
|
||||||
|
"@heroicons/react": "^2.2.0",
|
||||||
|
"@icons-pack/react-simple-icons": "^13.8.0",
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"@prisma-uml/parser": "workspace:*",
|
||||||
|
"@xyflow/react": "^12.4.0",
|
||||||
|
"clsx": "^2.1.0",
|
||||||
|
"html-to-image": "^1.11.0",
|
||||||
|
"next": "^16.1.0",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"use-debounce": "^10.0.4",
|
||||||
|
"zod": "^3.24.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
|
"@types/dagre": "^0.7.53",
|
||||||
|
"@types/react": "^19.1.0",
|
||||||
|
"@types/react-dom": "^19.1.0",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.5.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 222 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 884 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "Prisma UML Viewer",
|
||||||
|
"short_name": "Prisma UML",
|
||||||
|
"description": "A online tool to visualize and explore your Prisma schema as an interactive ERD diagram.",
|
||||||
|
"theme_color": "#2563eb",
|
||||||
|
"background_color": "#09090b",
|
||||||
|
"display": "standalone",
|
||||||
|
"start_url": "/",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/android-chrome-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/android-chrome-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://prisma.jessy-david.dev/sitemap.xml
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Export endpoint - converts Prisma schema to various diagram formats
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { parsePrismaSchema, toMermaid, toPlantUML, toDBML } from '@prisma-uml/parser';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const requestSchema = z.object({
|
||||||
|
schema: z.string().min(1, 'Schema is required'),
|
||||||
|
format: z.enum(['mermaid', 'plantuml', 'dbml']),
|
||||||
|
});
|
||||||
|
|
||||||
|
// map format names to renderer functions
|
||||||
|
const renderers = {
|
||||||
|
mermaid: toMermaid,
|
||||||
|
plantuml: toPlantUML,
|
||||||
|
dbml: toDBML,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type ExportFormat = keyof typeof renderers;
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const { schema, format } = requestSchema.parse(body);
|
||||||
|
|
||||||
|
// first parse the schema, then render to requested format
|
||||||
|
const parsed = await parsePrismaSchema(schema);
|
||||||
|
const render = renderers[format as ExportFormat];
|
||||||
|
const output = render(parsed);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, output, format });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Validation error', errors: error.errors },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ success: false, message: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: false, message: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* API endpoint to parse Prisma schemas
|
||||||
|
* Returns a structured representation of the schema for visualization
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { parsePrismaSchema, validatePrismaSchema } from '@prisma-uml/parser';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// validate incoming request body
|
||||||
|
const requestSchema = z.object({
|
||||||
|
schema: z.string().min(1, 'Schema is required'),
|
||||||
|
version: z.enum(['5', '6', '7']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const { schema, version } = requestSchema.parse(body);
|
||||||
|
|
||||||
|
// quick validation before parsing
|
||||||
|
const validation = validatePrismaSchema(schema);
|
||||||
|
if (!validation.valid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Invalid schema', errors: validation.errors },
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse the schema - version is auto-detected if not specified
|
||||||
|
const parsedSchema = await parsePrismaSchema(schema, {
|
||||||
|
version: version ? (parseInt(version) as 5 | 6 | 7) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: parsedSchema });
|
||||||
|
} catch (error) {
|
||||||
|
// handle validation errors from zod
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Validation error', errors: error.errors },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle parsing errors
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ success: false, message: error.message }, { status: 422 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// something unexpected happened
|
||||||
|
return NextResponse.json({ success: false, message: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: var(--font-ibm-plex-sans), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* React Flow Controls - Dark Theme */
|
||||||
|
.react-flow__controls {
|
||||||
|
background-color: #27272a !important;
|
||||||
|
border: 1px solid #3f3f46 !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__controls-button {
|
||||||
|
background-color: #27272a !important;
|
||||||
|
border: none !important;
|
||||||
|
border-bottom: 1px solid #3f3f46 !important;
|
||||||
|
fill: #a1a1aa !important;
|
||||||
|
color: #a1a1aa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__controls-button:hover {
|
||||||
|
background-color: #3f3f46 !important;
|
||||||
|
fill: #ffffff !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__controls-button:last-child {
|
||||||
|
border-bottom: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__controls-button svg {
|
||||||
|
fill: currentColor !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MiniMap - Dark Theme */
|
||||||
|
.react-flow__minimap {
|
||||||
|
background-color: #18181b !important;
|
||||||
|
border: 1px solid #3f3f46 !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { IBM_Plex_Sans } from 'next/font/google';
|
||||||
|
import './globals.css';
|
||||||
|
|
||||||
|
const ibmPlexSans = IBM_Plex_Sans({
|
||||||
|
subsets: ['latin'],
|
||||||
|
weight: ['300', '400', '500', '600', '700'],
|
||||||
|
variable: '--font-ibm-plex-sans',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: {
|
||||||
|
default: 'Prisma UML Viewer',
|
||||||
|
template: '%s | Prisma UML Viewer',
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
'A online tool to visualize and explore your Prisma schema as an interactive ERD diagram.',
|
||||||
|
keywords: [
|
||||||
|
'prisma',
|
||||||
|
'uml',
|
||||||
|
'erd',
|
||||||
|
'diagram',
|
||||||
|
'database',
|
||||||
|
'schema',
|
||||||
|
'visualization',
|
||||||
|
'prisma schema',
|
||||||
|
'database diagram',
|
||||||
|
'entity relationship diagram',
|
||||||
|
'prisma viewer',
|
||||||
|
'schema visualization',
|
||||||
|
'prisma 7',
|
||||||
|
'prisma 6',
|
||||||
|
'prisma 5',
|
||||||
|
],
|
||||||
|
authors: [{ name: 'Jessy DAVID', url: 'https://jessy-david.dev' }],
|
||||||
|
creator: 'Jessy DAVID',
|
||||||
|
publisher: 'Jessy DAVID',
|
||||||
|
metadataBase: new URL('https://prisma.jessy-david.dev'),
|
||||||
|
alternates: {
|
||||||
|
canonical: '/',
|
||||||
|
},
|
||||||
|
icons: {
|
||||||
|
icon: [
|
||||||
|
{ url: '/favicon-16x16.png', sizes: '16x16', type: 'image/png' },
|
||||||
|
{ url: '/favicon-32x32.png', sizes: '32x32', type: 'image/png' },
|
||||||
|
],
|
||||||
|
apple: '/apple-touch-icon.png',
|
||||||
|
shortcut: '/favicon.ico',
|
||||||
|
},
|
||||||
|
manifest: '/manifest.json',
|
||||||
|
openGraph: {
|
||||||
|
title: 'Prisma UML Viewer',
|
||||||
|
description:
|
||||||
|
' A online tool to visualize and explore your Prisma schema as an interactive ERD diagram. ',
|
||||||
|
type: 'website',
|
||||||
|
locale: 'en_US',
|
||||||
|
url: 'https://prisma.jessy-david.dev',
|
||||||
|
siteName: 'Prisma UML Viewer',
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: 'Prisma UML Viewer',
|
||||||
|
description: 'Visualize your Prisma schemas as interactive UML/ERD diagrams',
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
googleBot: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
'max-video-preview': -1,
|
||||||
|
'max-image-preview': 'large',
|
||||||
|
'max-snippet': -1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
category: 'technology',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className="dark">
|
||||||
|
<body className={`${ibmPlexSans.variable} font-sans antialiased`}>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// Main page component
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { SchemaEditor } from '@/components/schema-editor';
|
||||||
|
import { UMLDiagram } from '@/components/uml-diagram';
|
||||||
|
import { DiagramControls } from '@/components/diagram-controls';
|
||||||
|
import { ExportButtons } from '@/components/export-buttons';
|
||||||
|
import { Header } from '@/components/header';
|
||||||
|
import { Footer } from '@/components/footer';
|
||||||
|
import type { PrismaSchema } from '@prisma-uml/parser';
|
||||||
|
|
||||||
|
// I went with 40% for the editor panel, feels like a good balance
|
||||||
|
const DEFAULT_PANEL_SIZE = 40;
|
||||||
|
const MIN_PANEL = 20;
|
||||||
|
const MAX_PANEL = 80;
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const [schema, setSchema] = useState<PrismaSchema | null>(null);
|
||||||
|
const [rawSchema, setRawSchema] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [panelSize, setPanelSize] = useState(DEFAULT_PANEL_SIZE);
|
||||||
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
|
||||||
|
// when parsing succeeds, update both the parsed schema and raw text
|
||||||
|
const handleSchemaChange = useCallback((parsedSchema: PrismaSchema, raw: string) => {
|
||||||
|
setSchema(parsedSchema);
|
||||||
|
setRawSchema(raw);
|
||||||
|
setError(null); // clear any previous errors
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// if there's an error, we still want to show it but clear the diagram
|
||||||
|
const handleError = useCallback((errorMessage: string | null) => {
|
||||||
|
setError(errorMessage);
|
||||||
|
if (errorMessage) setSchema(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback(() => {
|
||||||
|
setIsResizing(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMouseUp = useCallback(() => {
|
||||||
|
setIsResizing(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// drag handler for the resizable divider
|
||||||
|
const handleMouseMove = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (!isResizing) return;
|
||||||
|
|
||||||
|
const container = e.currentTarget as HTMLElement;
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const percentage = ((e.clientX - rect.left) / rect.width) * 100;
|
||||||
|
|
||||||
|
// clamp to reasonable bounds so panels don't get too small
|
||||||
|
const clamped = Math.min(Math.max(percentage, MIN_PANEL), MAX_PANEL);
|
||||||
|
setPanelSize(clamped);
|
||||||
|
},
|
||||||
|
[isResizing]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col bg-zinc-950 text-zinc-100">
|
||||||
|
<Header>
|
||||||
|
<ExportButtons schema={schema} rawSchema={rawSchema} />
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<main
|
||||||
|
className="flex-1 flex overflow-hidden"
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseUp}
|
||||||
|
>
|
||||||
|
{/* Editor Panel */}
|
||||||
|
<div
|
||||||
|
className="border-r border-zinc-800 overflow-hidden flex flex-col"
|
||||||
|
style={{ width: `${panelSize}%` }}
|
||||||
|
>
|
||||||
|
<div className="px-4 py-2 border-b border-zinc-800 bg-zinc-900/50">
|
||||||
|
<h2 className="text-sm font-medium text-zinc-400">Prisma Schema</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<SchemaEditor onSchemaChange={handleSchemaChange} onError={handleError} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resizer */}
|
||||||
|
<div
|
||||||
|
className={`w-1 cursor-col-resize transition-colors ${
|
||||||
|
isResizing ? 'bg-blue-600' : 'bg-zinc-800 hover:bg-blue-600'
|
||||||
|
}`}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Diagram Panel */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<DiagramControls schema={schema} />
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center p-4">
|
||||||
|
<div className="max-w-md p-4 bg-red-500/10 border border-red-500/50 rounded-lg">
|
||||||
|
<h3 className="text-red-400 font-medium mb-2">Parsing Error</h3>
|
||||||
|
<p className="text-red-300 text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<UMLDiagram schema={schema} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// Toolbar above the diagram showing schema stats
|
||||||
|
|
||||||
|
import type { PrismaSchema } from '@prisma-uml/parser';
|
||||||
|
import { CubeIcon, RectangleStackIcon, CircleStackIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
interface DiagramControlsProps {
|
||||||
|
schema: PrismaSchema | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiagramControls({ schema }: DiagramControlsProps) {
|
||||||
|
// show a minimal bar when no schema is loaded
|
||||||
|
if (!schema) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-2 border-b border-zinc-800 bg-zinc-900/50">
|
||||||
|
<div className="flex items-center gap-4 text-zinc-500 text-sm">
|
||||||
|
<span>UML Diagram</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-2 border-b border-zinc-800 bg-zinc-900/50">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<h2 className="text-sm font-medium text-zinc-400">UML Diagram</h2>
|
||||||
|
<div className="h-4 w-px bg-zinc-700" />
|
||||||
|
<span className="text-xs text-zinc-500 flex items-center gap-1.5">
|
||||||
|
<CircleStackIcon className="w-4 h-4" />
|
||||||
|
Prisma {schema.version}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-xs text-zinc-500">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<CubeIcon className="w-4 h-4 text-blue-400" />
|
||||||
|
{schema.models.length} models
|
||||||
|
</span>
|
||||||
|
{schema.enums.length > 0 && (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<RectangleStackIcon className="w-4 h-4 text-purple-400" />
|
||||||
|
{schema.enums.length} enums
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import type { PrismaSchema } from '@prisma-uml/parser';
|
||||||
|
import {
|
||||||
|
ArrowDownTrayIcon,
|
||||||
|
DocumentDuplicateIcon,
|
||||||
|
CheckIcon,
|
||||||
|
PhotoIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import { useExportImage, type ImageFormat } from '@/hooks/use-export-image';
|
||||||
|
|
||||||
|
type ExportFormat = 'mermaid' | 'plantuml' | 'dbml';
|
||||||
|
|
||||||
|
interface ExportButtonsProps {
|
||||||
|
schema: PrismaSchema | null;
|
||||||
|
rawSchema: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportButtons({ schema, rawSchema }: ExportButtonsProps) {
|
||||||
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
const [copiedFormat, setCopiedFormat] = useState<string | null>(null);
|
||||||
|
const [showDropdown, setShowDropdown] = useState(false);
|
||||||
|
const { downloadImage } = useExportImage();
|
||||||
|
|
||||||
|
const exportAs = useCallback(
|
||||||
|
async (format: ExportFormat) => {
|
||||||
|
if (!rawSchema) return;
|
||||||
|
|
||||||
|
setIsExporting(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/export', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ schema: rawSchema, format }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
await navigator.clipboard.writeText(result.output);
|
||||||
|
setCopiedFormat(format);
|
||||||
|
setTimeout(() => setCopiedFormat(null), 2000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Export failed:', error);
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
setShowDropdown(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[rawSchema]
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloadAs = useCallback(
|
||||||
|
async (format: ExportFormat) => {
|
||||||
|
if (!rawSchema) return;
|
||||||
|
|
||||||
|
setIsExporting(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/export', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ schema: rawSchema, format }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const extensions: Record<ExportFormat, string> = {
|
||||||
|
mermaid: 'mmd',
|
||||||
|
plantuml: 'puml',
|
||||||
|
dbml: 'dbml',
|
||||||
|
};
|
||||||
|
|
||||||
|
const blob = new Blob([result.output], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `schema.${extensions[format]}`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Download failed:', error);
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
setShowDropdown(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[rawSchema]
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloadImageAs = useCallback(
|
||||||
|
async (format: ImageFormat) => {
|
||||||
|
setIsExporting(true);
|
||||||
|
try {
|
||||||
|
await downloadImage({
|
||||||
|
format,
|
||||||
|
filename: 'prisma-diagram',
|
||||||
|
backgroundColor: '#18181b',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Image export failed:', error);
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
setShowDropdown(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[downloadImage]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!schema) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDropdown(!showDropdown)}
|
||||||
|
disabled={isExporting}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 disabled:bg-blue-600/50 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowDownTrayIcon className="w-4 h-4" />
|
||||||
|
<span>Export</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showDropdown && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setShowDropdown(false)} />
|
||||||
|
<div className="absolute right-0 top-full mt-2 z-50 w-56 bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl overflow-hidden">
|
||||||
|
<div className="p-2">
|
||||||
|
<p className="text-xs text-zinc-500 px-2 py-1 mb-1">Copy to clipboard</p>
|
||||||
|
{(['mermaid', 'plantuml', 'dbml'] as ExportFormat[]).map((format) => (
|
||||||
|
<button
|
||||||
|
key={format}
|
||||||
|
onClick={() => exportAs(format)}
|
||||||
|
className="w-full flex items-center gap-2 px-2 py-1.5 text-sm text-zinc-300 hover:bg-zinc-700 rounded transition-colors"
|
||||||
|
>
|
||||||
|
{copiedFormat === format ? (
|
||||||
|
<CheckIcon className="w-4 h-4 text-green-400" />
|
||||||
|
) : (
|
||||||
|
<DocumentDuplicateIcon className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
<span className="capitalize">{format}</span>
|
||||||
|
{copiedFormat === format && (
|
||||||
|
<span className="ml-auto text-xs text-green-400">Copied!</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-zinc-700 p-2">
|
||||||
|
<p className="text-xs text-zinc-500 px-2 py-1 mb-1">Download Code</p>
|
||||||
|
{(['mermaid', 'plantuml', 'dbml'] as ExportFormat[]).map((format) => (
|
||||||
|
<button
|
||||||
|
key={`download-${format}`}
|
||||||
|
onClick={() => downloadAs(format)}
|
||||||
|
className="w-full flex items-center gap-2 px-2 py-1.5 text-sm text-zinc-300 hover:bg-zinc-700 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowDownTrayIcon className="w-4 h-4" />
|
||||||
|
<span className="capitalize">{format}</span>
|
||||||
|
<span className="ml-auto text-xs text-zinc-500">
|
||||||
|
.{format === 'mermaid' ? 'mmd' : format === 'plantuml' ? 'puml' : 'dbml'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-zinc-700 p-2">
|
||||||
|
<p className="text-xs text-zinc-500 px-2 py-1 mb-1">Download Image</p>
|
||||||
|
{(['png', 'svg', 'jpeg'] as ImageFormat[]).map((format) => (
|
||||||
|
<button
|
||||||
|
key={`image-${format}`}
|
||||||
|
onClick={() => downloadImageAs(format)}
|
||||||
|
className="w-full flex items-center gap-2 px-2 py-1.5 text-sm text-zinc-300 hover:bg-zinc-700 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<PhotoIcon className="w-4 h-4" />
|
||||||
|
<span className="uppercase">{format}</span>
|
||||||
|
<span className="ml-auto text-xs text-zinc-500">.{format}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// Simple footer with GitHub link and credits
|
||||||
|
|
||||||
|
import { SiGithub } from '@icons-pack/react-simple-icons';
|
||||||
|
import packageJson from '../../package.json';
|
||||||
|
|
||||||
|
export function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="px-6 py-3 border-t border-zinc-800 bg-zinc-900/50 flex items-center justify-between">
|
||||||
|
{/* repo link */}
|
||||||
|
<div className="flex items-center gap-4 text-xs">
|
||||||
|
<a
|
||||||
|
href="https://github.com/jessy-david-dev/Prisma-UML-Viewer"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-zinc-500 hover:text-white transition-colors flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<SiGithub className="w-4 h-4" />
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
<img
|
||||||
|
src={`https://img.shields.io/badge/version-${packageJson.version}-green`}
|
||||||
|
alt={`Version ${packageJson.version}`}
|
||||||
|
className="h-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-zinc-600 text-xs">
|
||||||
|
A tool by{' '}
|
||||||
|
<a
|
||||||
|
href="https://jessy-david.dev"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-zinc-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Jessy DAVID
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Site header with branding and nav links
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { SiPrisma } from '@icons-pack/react-simple-icons';
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({ children }: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<header className="flex items-center justify-between px-6 py-4 border-b border-zinc-800 bg-zinc-900/50">
|
||||||
|
{/* logo + title */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SiPrisma className="w-6 h-6 text-white" />
|
||||||
|
<h1 className="text-xl font-bold text-white">Prisma UML Viewer</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-2 py-0.5 text-xs bg-blue-600 text-white rounded-full font-medium">
|
||||||
|
v5/v6/v7
|
||||||
|
</span>
|
||||||
|
<span className="px-2 py-0.5 text-xs bg-zinc-700 text-zinc-300 rounded-full">beta</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* right side: docs link + export buttons */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<a
|
||||||
|
href="https://www.prisma.io/docs"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-zinc-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Documentation Prisma
|
||||||
|
</a>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export { SchemaEditor } from './schema-editor';
|
||||||
|
export { UMLDiagram } from './uml-diagram';
|
||||||
|
export { DiagramControls } from './diagram-controls';
|
||||||
|
export { ExportButtons } from './export-buttons';
|
||||||
|
export { Header } from './header';
|
||||||
|
export { Footer } from './footer';
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// ReactFlow node for Prisma enums
|
||||||
|
// Purple theme to distinguish from models (blue)
|
||||||
|
|
||||||
|
import { memo } from 'react';
|
||||||
|
import { Handle, Position } from '@xyflow/react';
|
||||||
|
import type { NodeProps, Node } from '@xyflow/react';
|
||||||
|
import type { PrismaEnum } from '@prisma-uml/parser';
|
||||||
|
|
||||||
|
type EnumNodeData = { enum: PrismaEnum };
|
||||||
|
type EnumNodeType = Node<EnumNodeData, 'enum'>;
|
||||||
|
|
||||||
|
export const EnumNode = memo(function EnumNode({ data }: NodeProps<EnumNodeType>) {
|
||||||
|
const enumDef = data.enum;
|
||||||
|
const values = enumDef.values;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl min-w-50 max-w-65 overflow-hidden">
|
||||||
|
<div className="bg-purple-600 px-4 py-3 flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-purple-300" />
|
||||||
|
<span className="text-white font-semibold">{enumDef.name}</span>
|
||||||
|
<span className="ml-auto text-purple-200 text-xs bg-purple-700/50 px-2 py-0.5 rounded">
|
||||||
|
enum
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-zinc-800 max-h-62.5 overflow-y-auto">
|
||||||
|
{enumDef.values.map((value, index) => (
|
||||||
|
<div
|
||||||
|
key={value.name}
|
||||||
|
className="px-4 py-2 flex items-center justify-between text-sm hover:bg-zinc-800/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-zinc-500 text-xs font-mono w-4">{index}</span>
|
||||||
|
<span className="text-zinc-300">{value.name}</span>
|
||||||
|
</div>
|
||||||
|
{value.dbName && (
|
||||||
|
<span className="text-zinc-500 text-xs font-mono">
|
||||||
|
@map("{value.dbName}")
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{enumDef.documentation && (
|
||||||
|
<div className="px-4 py-2 bg-zinc-800/50 text-xs text-zinc-500 border-t border-zinc-800">
|
||||||
|
{enumDef.documentation}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
id="target"
|
||||||
|
className="w-3! h-3! bg-purple-500! border-2! border-purple-300!"
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
id="source"
|
||||||
|
className="w-3! h-3! bg-purple-500! border-2! border-purple-300!"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { ModelNode } from './model-node';
|
||||||
|
export { EnumNode } from './enum-node';
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// Custom ReactFlow node for Prisma models
|
||||||
|
// Shows fields with their types and various indicators (PK, FK, unique, etc)
|
||||||
|
|
||||||
|
import { memo } from 'react';
|
||||||
|
import { Handle, Position } from '@xyflow/react';
|
||||||
|
import type { NodeProps, Node } from '@xyflow/react';
|
||||||
|
import type { PrismaModel, PrismaField } from '@prisma-uml/parser';
|
||||||
|
import {
|
||||||
|
KeyIcon,
|
||||||
|
LinkIcon,
|
||||||
|
ListBulletIcon,
|
||||||
|
QuestionMarkCircleIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
type ModelNodeData = { model: PrismaModel };
|
||||||
|
type ModelNodeType = Node<ModelNodeData, 'model'>;
|
||||||
|
|
||||||
|
export const ModelNode = memo(function ModelNode({ data }: NodeProps<ModelNodeType>) {
|
||||||
|
const { model } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl min-w-70 max-w-80 overflow-visible relative">
|
||||||
|
<div className="bg-blue-600 px-4 py-3 flex items-center justify-between rounded-t-lg">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-blue-300" />
|
||||||
|
<span className="text-white font-semibold">{model.name}</span>
|
||||||
|
</div>
|
||||||
|
{model.dbName && (
|
||||||
|
<span className="text-blue-200 text-xs bg-blue-700/50 px-2 py-0.5 rounded">
|
||||||
|
{model.dbName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-zinc-800 max-h-75 overflow-y-auto">
|
||||||
|
{model.fields.map((field) => (
|
||||||
|
<FieldRow key={field.name} field={field} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{model.primaryKey && (
|
||||||
|
<div className="px-4 py-2 bg-zinc-800/50 text-xs text-zinc-500 rounded-b-lg">
|
||||||
|
Composite PK: {model.primaryKey.fields.join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
id="target"
|
||||||
|
className="w-3! h-3! bg-blue-500! border-2! border-blue-300!"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
id="source"
|
||||||
|
className="w-3! h-3! bg-blue-500! border-2! border-blue-300!"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// individual field row with icons and badges
|
||||||
|
function FieldRow({ field }: { field: PrismaField }) {
|
||||||
|
const isRelation = field.kind === 'object';
|
||||||
|
|
||||||
|
// build the type string (e.g., "User[]" or "String?")
|
||||||
|
let typeStr = field.type;
|
||||||
|
if (field.isList) typeStr += '[]';
|
||||||
|
else if (!field.isRequired) typeStr += '?';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-2 flex items-center justify-between text-sm hover:bg-zinc-800/50 transition-colors group">
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{field.isId && <KeyIcon className="w-4 h-4 text-yellow-500" title="Primary Key" />}
|
||||||
|
{isRelation && <LinkIcon className="w-4 h-4 text-blue-400" title="Relation" />}
|
||||||
|
{field.isList && <ListBulletIcon className="w-4 h-4 text-green-400" title="Array" />}
|
||||||
|
{!field.isRequired && !field.isId && (
|
||||||
|
<QuestionMarkCircleIcon className="w-4 h-4 text-zinc-500" title="Optional" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={`truncate ${field.isId ? 'font-semibold text-yellow-400' : 'text-zinc-300'}`}
|
||||||
|
>
|
||||||
|
{field.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0 ml-2">
|
||||||
|
<span className={`font-mono text-xs ${isRelation ? 'text-blue-400' : 'text-zinc-500'}`}>
|
||||||
|
{typeStr}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{field.isUnique && !field.isId && (
|
||||||
|
<span className="px-1.5 py-0.5 text-2.5 bg-purple-500/20 text-purple-400 rounded font-medium">
|
||||||
|
UK
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field.isOmitted && (
|
||||||
|
<span className="px-1.5 py-0.5 text-2.5 bg-orange-500/20 text-orange-400 rounded font-medium">
|
||||||
|
omit
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// Monaco-based editor for Prisma schemas
|
||||||
|
// Uses debouncing to avoid hammering the API on every keystroke
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef } from 'react';
|
||||||
|
import Editor from '@monaco-editor/react';
|
||||||
|
import type { OnMount } from '@monaco-editor/react';
|
||||||
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
|
import type { PrismaSchema } from '@prisma-uml/parser';
|
||||||
|
|
||||||
|
interface SchemaEditorProps {
|
||||||
|
initialValue?: string;
|
||||||
|
onSchemaChange: (schema: PrismaSchema, raw: string) => void;
|
||||||
|
onError: (error: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// sample schema to show when the page loads
|
||||||
|
// includes common patterns: relations, enums, optional fields
|
||||||
|
const DEFAULT_SCHEMA = `// Paste your Prisma schema here
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
email String @unique
|
||||||
|
name String?
|
||||||
|
role Role @default(USER)
|
||||||
|
posts Post[]
|
||||||
|
profile Profile?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Profile {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
bio String?
|
||||||
|
avatar String?
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
userId Int @unique
|
||||||
|
}
|
||||||
|
|
||||||
|
model Post {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
title String
|
||||||
|
content String?
|
||||||
|
published Boolean @default(false)
|
||||||
|
author User @relation(fields: [authorId], references: [id])
|
||||||
|
authorId Int
|
||||||
|
tags Tag[]
|
||||||
|
comments Comment[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Comment {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
content String
|
||||||
|
post Post @relation(fields: [postId], references: [id])
|
||||||
|
postId Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model Tag {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String @unique
|
||||||
|
posts Post[]
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
ADMIN
|
||||||
|
USER
|
||||||
|
GUEST
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function SchemaEditor({
|
||||||
|
initialValue = DEFAULT_SCHEMA,
|
||||||
|
onSchemaChange,
|
||||||
|
onError,
|
||||||
|
}: SchemaEditorProps) {
|
||||||
|
const [value, setValue] = useState(initialValue);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const editorRef = useRef<unknown>(null);
|
||||||
|
|
||||||
|
// sends the schema to our API endpoint for parsing
|
||||||
|
async function parseSchema(schema: string) {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/parse', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ schema }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok || !result.success) {
|
||||||
|
// try to extract a meaningful error message
|
||||||
|
const msg = result.errors
|
||||||
|
? result.errors.map((e: { message?: string }) => e.message ?? String(e)).join(', ')
|
||||||
|
: (result.message ?? 'Parsing error');
|
||||||
|
onError(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onError(null);
|
||||||
|
onSchemaChange(result.data, schema);
|
||||||
|
} catch (err) {
|
||||||
|
// network error or something unexpected
|
||||||
|
onError('Server connection error');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 500ms debounce seems like a good tradeoff between responsiveness and API load
|
||||||
|
const debouncedParse = useDebouncedCallback(parseSchema, 500);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(newValue: string | undefined) => {
|
||||||
|
if (newValue === undefined) return;
|
||||||
|
setValue(newValue);
|
||||||
|
debouncedParse(newValue);
|
||||||
|
},
|
||||||
|
[debouncedParse]
|
||||||
|
);
|
||||||
|
|
||||||
|
// parse the initial schema when editor mounts
|
||||||
|
const handleEditorMount: OnMount = (editor) => {
|
||||||
|
editorRef.current = editor;
|
||||||
|
parseSchema(initialValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full relative">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="absolute top-2 right-2 z-10">
|
||||||
|
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Editor
|
||||||
|
height="100%"
|
||||||
|
defaultLanguage="prisma"
|
||||||
|
theme="vs-dark"
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
onMount={handleEditorMount}
|
||||||
|
options={{
|
||||||
|
minimap: { enabled: false },
|
||||||
|
fontSize: 14,
|
||||||
|
lineNumbers: 'on',
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
automaticLayout: true,
|
||||||
|
tabSize: 2,
|
||||||
|
wordWrap: 'on',
|
||||||
|
padding: { top: 16 },
|
||||||
|
scrollbar: {
|
||||||
|
verticalScrollbarSize: 8,
|
||||||
|
horizontalScrollbarSize: 8,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ReactFlow,
|
||||||
|
Background,
|
||||||
|
Controls,
|
||||||
|
MiniMap,
|
||||||
|
useNodesState,
|
||||||
|
useEdgesState,
|
||||||
|
useReactFlow,
|
||||||
|
ReactFlowProvider,
|
||||||
|
MarkerType,
|
||||||
|
BackgroundVariant,
|
||||||
|
Panel,
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import type { Node, Edge } from '@xyflow/react';
|
||||||
|
import '@xyflow/react/dist/style.css';
|
||||||
|
import { ArrowsPointingOutIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
import { ModelNode } from './nodes/model-node';
|
||||||
|
import { EnumNode } from './nodes/enum-node';
|
||||||
|
import { useAutoLayout } from '../hooks';
|
||||||
|
import type { PrismaSchema } from '@prisma-uml/parser';
|
||||||
|
|
||||||
|
// custom node types for models and enums
|
||||||
|
const nodeTypes = {
|
||||||
|
model: ModelNode,
|
||||||
|
enum: EnumNode,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface UMLDiagramProps {
|
||||||
|
schema: PrismaSchema | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrapper component to provide ReactFlow context
|
||||||
|
export function UMLDiagram({ schema }: UMLDiagramProps) {
|
||||||
|
return (
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<DiagramCanvas schema={schema} />
|
||||||
|
</ReactFlowProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the actual diagram logic lives here
|
||||||
|
function DiagramCanvas({ schema }: UMLDiagramProps) {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const { fitView } = useReactFlow();
|
||||||
|
const { getLayoutedElements } = useAutoLayout();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { nodes: initialNodes, edges: initialEdges } = useMemo(() => {
|
||||||
|
if (!schema) return { nodes: [], edges: [] };
|
||||||
|
return schemaToReactFlow(schema);
|
||||||
|
}, [schema]);
|
||||||
|
|
||||||
|
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||||||
|
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (schema) {
|
||||||
|
const { nodes: newNodes, edges: newEdges } = schemaToReactFlow(schema);
|
||||||
|
setNodes(newNodes);
|
||||||
|
setEdges(newEdges);
|
||||||
|
}
|
||||||
|
}, [schema, setNodes, setEdges]);
|
||||||
|
|
||||||
|
// auto-layout using dagre - tweaked these values until it looked good
|
||||||
|
const onAutoLayout = useCallback(() => {
|
||||||
|
const layoutConfig = {
|
||||||
|
direction: 'LR' as const,
|
||||||
|
nodeWidth: 300,
|
||||||
|
baseNodeHeight: 150,
|
||||||
|
fieldHeight: 36,
|
||||||
|
rankSep: 80, // space between columns
|
||||||
|
nodeSep: 30, // space between nodes in same column
|
||||||
|
};
|
||||||
|
|
||||||
|
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
layoutConfig
|
||||||
|
);
|
||||||
|
|
||||||
|
setNodes(layoutedNodes);
|
||||||
|
setEdges(layoutedEdges);
|
||||||
|
|
||||||
|
// small delay before fitting view, otherwise it doesn't work properly
|
||||||
|
setTimeout(() => fitView({ padding: 0.2, duration: 300 }), 50);
|
||||||
|
}, [nodes, edges, getLayoutedElements, setNodes, setEdges, fitView]);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center bg-zinc-900">
|
||||||
|
<div className="text-zinc-500">Loading diagram...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!schema) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center bg-zinc-900">
|
||||||
|
<div className="text-center text-zinc-500">
|
||||||
|
<p className="text-lg mb-2">No schema to display</p>
|
||||||
|
<p className="text-sm">Enter a valid Prisma schema in the editor</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 w-full bg-zinc-900">
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
onEdgesChange={onEdgesChange}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
fitView
|
||||||
|
fitViewOptions={{ padding: 0.2 }}
|
||||||
|
attributionPosition="bottom-left"
|
||||||
|
proOptions={{ hideAttribution: true }}
|
||||||
|
>
|
||||||
|
<Background color="#3f3f46" gap={50} variant={BackgroundVariant.Lines} lineWidth={1} />
|
||||||
|
<Controls className="bg-zinc-800! border-zinc-700! shadow-lg!" showInteractive={false} />
|
||||||
|
<MiniMap
|
||||||
|
nodeStrokeColor="#3b82f6"
|
||||||
|
nodeColor="#18181b"
|
||||||
|
maskColor="rgba(0, 0, 0, 0.8)"
|
||||||
|
className="bg-zinc-800! border-zinc-700!"
|
||||||
|
/>
|
||||||
|
<Panel position="top-left" className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onAutoLayout}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg text-sm text-zinc-300 transition-colors shadow-lg"
|
||||||
|
title="Automatically organize the diagram layout"
|
||||||
|
>
|
||||||
|
<ArrowsPointingOutIcon className="w-4 h-4" />
|
||||||
|
<span>Auto-organize</span>
|
||||||
|
</button>
|
||||||
|
</Panel>
|
||||||
|
<Panel position="top-right" className="text-xs text-zinc-500">
|
||||||
|
{schema.models.length} models • {schema.enums.length} enums
|
||||||
|
</Panel>
|
||||||
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function schemaToReactFlow(schema: PrismaSchema): { nodes: Node[]; edges: Edge[] } {
|
||||||
|
const nodes: Node[] = [];
|
||||||
|
const edges: Edge[] = [];
|
||||||
|
|
||||||
|
const GRID_COLS = Math.min(3, Math.ceil(Math.sqrt(schema.models.length + schema.enums.length)));
|
||||||
|
const NODE_WIDTH = 300;
|
||||||
|
const NODE_HEIGHT = 250;
|
||||||
|
const GAP_X = 120;
|
||||||
|
const GAP_Y = 100;
|
||||||
|
|
||||||
|
schema.models.forEach((model, index) => {
|
||||||
|
const col = index % GRID_COLS;
|
||||||
|
const row = Math.floor(index / GRID_COLS);
|
||||||
|
|
||||||
|
nodes.push({
|
||||||
|
id: model.name,
|
||||||
|
type: 'model',
|
||||||
|
position: {
|
||||||
|
x: col * (NODE_WIDTH + GAP_X),
|
||||||
|
y: row * (NODE_HEIGHT + GAP_Y),
|
||||||
|
},
|
||||||
|
data: { model },
|
||||||
|
});
|
||||||
|
|
||||||
|
model.fields
|
||||||
|
.filter((field) => field.kind === 'object' && field.relationFromFields?.length)
|
||||||
|
.forEach((field) => {
|
||||||
|
const targetModel = field.type.replace('[]', '').replace('?', '');
|
||||||
|
|
||||||
|
edges.push({
|
||||||
|
id: `${model.name}-${field.name}-${targetModel}`,
|
||||||
|
source: model.name,
|
||||||
|
target: targetModel,
|
||||||
|
sourceHandle: 'source',
|
||||||
|
targetHandle: 'target',
|
||||||
|
type: 'smoothstep',
|
||||||
|
animated: false,
|
||||||
|
markerEnd: {
|
||||||
|
type: MarkerType.ArrowClosed,
|
||||||
|
color: '#3b82f6',
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
stroke: '#3b82f6',
|
||||||
|
strokeWidth: 2,
|
||||||
|
},
|
||||||
|
label: field.relationName || undefined,
|
||||||
|
labelStyle: {
|
||||||
|
fill: '#a1a1aa',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
labelBgStyle: {
|
||||||
|
fill: '#18181b',
|
||||||
|
fillOpacity: 0.9,
|
||||||
|
},
|
||||||
|
labelBgPadding: [4, 4] as [number, number],
|
||||||
|
labelBgBorderRadius: 4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
schema.enums.forEach((enumDef, index) => {
|
||||||
|
const totalModels = schema.models.length;
|
||||||
|
const enumIndex = totalModels + index;
|
||||||
|
const col = enumIndex % GRID_COLS;
|
||||||
|
const row = Math.floor(enumIndex / GRID_COLS);
|
||||||
|
|
||||||
|
nodes.push({
|
||||||
|
id: enumDef.name,
|
||||||
|
type: 'enum',
|
||||||
|
position: {
|
||||||
|
x: col * (NODE_WIDTH + GAP_X),
|
||||||
|
y: row * (NODE_HEIGHT + GAP_Y),
|
||||||
|
},
|
||||||
|
data: { enum: enumDef },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { nodes, edges };
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { useExportImage, type ImageFormat } from './use-export-image';
|
||||||
|
export { useAutoLayout } from './use-auto-layout';
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import Dagre from '@dagrejs/dagre';
|
||||||
|
import type { Node, Edge } from '@xyflow/react';
|
||||||
|
|
||||||
|
interface LayoutOptions {
|
||||||
|
direction?: 'TB' | 'LR' | 'BT' | 'RL';
|
||||||
|
nodeWidth?: number;
|
||||||
|
baseNodeHeight?: number;
|
||||||
|
fieldHeight?: number;
|
||||||
|
rankSep?: number;
|
||||||
|
nodeSep?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateNodeHeight(node: Node, baseHeight: number, fieldHeight: number): number {
|
||||||
|
const data = node.data as { model?: { fields?: unknown[] }; enum?: { values?: unknown[] } };
|
||||||
|
|
||||||
|
if (data.model?.fields) {
|
||||||
|
const fieldsCount = data.model.fields.length;
|
||||||
|
return Math.max(baseHeight, 52 + fieldsCount * 36 + 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.enum?.values) {
|
||||||
|
const valuesCount = data.enum.values.length;
|
||||||
|
return Math.max(baseHeight, 44 + valuesCount * 32 + 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAutoLayout() {
|
||||||
|
const getLayoutedElements = useCallback(
|
||||||
|
(
|
||||||
|
nodes: Node[],
|
||||||
|
edges: Edge[],
|
||||||
|
options: LayoutOptions = {}
|
||||||
|
): { nodes: Node[]; edges: Edge[] } => {
|
||||||
|
const {
|
||||||
|
direction = 'LR',
|
||||||
|
nodeWidth = 300,
|
||||||
|
baseNodeHeight = 150,
|
||||||
|
fieldHeight = 36,
|
||||||
|
rankSep = 100,
|
||||||
|
nodeSep = 50,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
|
||||||
|
|
||||||
|
g.setGraph({
|
||||||
|
rankdir: direction,
|
||||||
|
ranksep: rankSep,
|
||||||
|
nodesep: nodeSep,
|
||||||
|
marginx: 50,
|
||||||
|
marginy: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodeHeights = new Map<string, number>();
|
||||||
|
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
const height = calculateNodeHeight(node, baseNodeHeight, fieldHeight);
|
||||||
|
nodeHeights.set(node.id, height);
|
||||||
|
|
||||||
|
g.setNode(node.id, {
|
||||||
|
width: nodeWidth,
|
||||||
|
height: height,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
edges.forEach((edge) => {
|
||||||
|
g.setEdge(edge.source, edge.target);
|
||||||
|
});
|
||||||
|
|
||||||
|
Dagre.layout(g);
|
||||||
|
|
||||||
|
const layoutedNodes = nodes.map((node) => {
|
||||||
|
const nodeWithPosition = g.node(node.id);
|
||||||
|
const height = nodeHeights.get(node.id) || baseNodeHeight;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
position: {
|
||||||
|
x: nodeWithPosition.x - nodeWidth / 2,
|
||||||
|
y: nodeWithPosition.y - height / 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { nodes: layoutedNodes, edges };
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { getLayoutedElements };
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { toPng, toSvg, toJpeg } from 'html-to-image';
|
||||||
|
|
||||||
|
export type ImageFormat = 'png' | 'svg' | 'jpeg';
|
||||||
|
|
||||||
|
interface ExportImageOptions {
|
||||||
|
format: ImageFormat;
|
||||||
|
quality?: number;
|
||||||
|
backgroundColor?: string;
|
||||||
|
filename?: string;
|
||||||
|
includeGrid?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawGridBackground(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
gridSize: number = 50,
|
||||||
|
backgroundColor: string = '#18181b',
|
||||||
|
gridColor: string = '#3f3f46'
|
||||||
|
) {
|
||||||
|
ctx.fillStyle = backgroundColor;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
ctx.strokeStyle = gridColor;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
|
||||||
|
for (let x = 0; x <= width; x += gridSize) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, 0);
|
||||||
|
ctx.lineTo(x, height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let y = 0; y <= height; y += gridSize) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(width, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExportImage() {
|
||||||
|
const downloadImage = useCallback(async (options: ExportImageOptions) => {
|
||||||
|
const {
|
||||||
|
format,
|
||||||
|
quality = 0.92,
|
||||||
|
backgroundColor = '#18181b',
|
||||||
|
filename = 'prisma-schema',
|
||||||
|
includeGrid = true,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const reactFlowContainer = document.querySelector('.react-flow') as HTMLElement;
|
||||||
|
if (!reactFlowContainer) {
|
||||||
|
console.error('React Flow container not found');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeElements = document.querySelectorAll('.react-flow__node') as NodeListOf<HTMLElement>;
|
||||||
|
if (nodeElements.length === 0) {
|
||||||
|
console.error('No nodes to export');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewport = document.querySelector('.react-flow__viewport') as HTMLElement;
|
||||||
|
if (!viewport) {
|
||||||
|
console.error('React Flow viewport not found');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
|
||||||
|
nodeElements.forEach((nodeEl) => {
|
||||||
|
const transform = nodeEl.style.transform;
|
||||||
|
const match = transform.match(/translate\((-?[\d.]+)px,\s*(-?[\d.]+)px\)/);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const x = parseFloat(match[1]);
|
||||||
|
const y = parseFloat(match[2]);
|
||||||
|
const width = nodeEl.offsetWidth;
|
||||||
|
const height = nodeEl.offsetHeight;
|
||||||
|
|
||||||
|
minX = Math.min(minX, x);
|
||||||
|
minY = Math.min(minY, y);
|
||||||
|
maxX = Math.max(maxX, x + width);
|
||||||
|
maxY = Math.max(maxY, y + height);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (minX === Infinity) {
|
||||||
|
console.error('Could not calculate node bounds');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const padding = 80;
|
||||||
|
const contentWidth = maxX - minX;
|
||||||
|
const contentHeight = maxY - minY;
|
||||||
|
const imageWidth = Math.ceil(contentWidth + padding * 2);
|
||||||
|
const imageHeight = Math.ceil(contentHeight + padding * 2);
|
||||||
|
|
||||||
|
const originalTransform = viewport.style.transform;
|
||||||
|
|
||||||
|
viewport.style.transform = `translate(${-minX + padding}px, ${-minY + padding}px) scale(1)`;
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||||
|
|
||||||
|
const imageOptions = {
|
||||||
|
backgroundColor: includeGrid ? 'transparent' : backgroundColor,
|
||||||
|
width: imageWidth,
|
||||||
|
height: imageHeight,
|
||||||
|
quality,
|
||||||
|
pixelRatio: 2,
|
||||||
|
skipFonts: true,
|
||||||
|
cacheBust: true,
|
||||||
|
filter: (node: Element) => {
|
||||||
|
const classList = (node as HTMLElement).classList;
|
||||||
|
if (!classList) return true;
|
||||||
|
|
||||||
|
if (
|
||||||
|
classList.contains('react-flow__controls') ||
|
||||||
|
classList.contains('react-flow__minimap') ||
|
||||||
|
classList.contains('react-flow__panel') ||
|
||||||
|
classList.contains('react-flow__background')
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const diagramDataUrl = await toPng(reactFlowContainer, imageOptions);
|
||||||
|
|
||||||
|
viewport.style.transform = originalTransform;
|
||||||
|
|
||||||
|
if (includeGrid && format !== 'svg') {
|
||||||
|
const finalCanvas = document.createElement('canvas');
|
||||||
|
const pixelRatio = 2;
|
||||||
|
finalCanvas.width = imageWidth * pixelRatio;
|
||||||
|
finalCanvas.height = imageHeight * pixelRatio;
|
||||||
|
const ctx = finalCanvas.getContext('2d')!;
|
||||||
|
|
||||||
|
ctx.scale(pixelRatio, pixelRatio);
|
||||||
|
|
||||||
|
drawGridBackground(ctx, imageWidth, imageHeight, 50, backgroundColor, '#3f3f46');
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
img.onload = () => resolve();
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = diagramDataUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.drawImage(img, 0, 0, imageWidth, imageHeight);
|
||||||
|
|
||||||
|
let finalDataUrl: string;
|
||||||
|
if (format === 'jpeg') {
|
||||||
|
finalDataUrl = finalCanvas.toDataURL('image/jpeg', quality);
|
||||||
|
} else {
|
||||||
|
finalDataUrl = finalCanvas.toDataURL('image/png');
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `${filename}.${format}`;
|
||||||
|
link.href = finalDataUrl;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
} else {
|
||||||
|
let dataUrl: string;
|
||||||
|
|
||||||
|
if (format === 'svg') {
|
||||||
|
viewport.style.transform = `translate(${-minX + padding}px, ${-minY + padding}px) scale(1)`;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||||
|
dataUrl = await toSvg(reactFlowContainer, { ...imageOptions, backgroundColor });
|
||||||
|
viewport.style.transform = originalTransform;
|
||||||
|
} else {
|
||||||
|
dataUrl = diagramDataUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `${filename}.${format}`;
|
||||||
|
link.href = dataUrl;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to export image:', error);
|
||||||
|
viewport.style.transform = originalTransform;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { downloadImage };
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { parsePrismaSchema, validatePrismaSchema, detectPrismaVersion } from './prisma-parser';
|
||||||
|
export type { PrismaSchema } from './prisma-parser';
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import {
|
||||||
|
parsePrismaSchema as parse,
|
||||||
|
validatePrismaSchema,
|
||||||
|
detectPrismaVersion,
|
||||||
|
type PrismaSchema,
|
||||||
|
type ParserOptions,
|
||||||
|
} from '@prisma-uml/parser';
|
||||||
|
|
||||||
|
export async function parsePrismaSchema(
|
||||||
|
schemaContent: string,
|
||||||
|
options?: ParserOptions
|
||||||
|
): Promise<PrismaSchema> {
|
||||||
|
return parse(schemaContent, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { validatePrismaSchema, detectPrismaVersion };
|
||||||
|
export type { PrismaSchema };
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge Tailwind CSS classes with clsx
|
||||||
|
*/
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeSchema(schema: string): string {
|
||||||
|
return btoa(encodeURIComponent(schema));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeSchema(encoded: string): string {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(atob(encoded));
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadFile(content: string, filename: string, mimeType = 'text/plain'): void {
|
||||||
|
const blob = new Blob([content], { type: mimeType });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
declare module '*.css' {
|
||||||
|
const content: { [className: string]: string };
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
"@prisma-uml/parser": ["./../../packages/prisma-parser/src"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "prisma-uml-viewer",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "turbo dev",
|
||||||
|
"build": "turbo build",
|
||||||
|
"start": "pnpm --filter @prisma-uml/web start",
|
||||||
|
"lint": "turbo lint",
|
||||||
|
"test": "turbo test",
|
||||||
|
"typecheck": "turbo typecheck",
|
||||||
|
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\""
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"prettier": "^3.4.0",
|
||||||
|
"turbo": "^2.4.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@9.15.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# @prisma-uml/parser
|
||||||
|
|
||||||
|
Internal package for parsing Prisma schemas.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Parse Prisma schemas (v5, v6, v7)
|
||||||
|
- Auto-detect Prisma version
|
||||||
|
- Export to Mermaid, PlantUML, DBML
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { parsePrismaSchema, toMermaid } from '@prisma-uml/parser';
|
||||||
|
|
||||||
|
const parsed = await parsePrismaSchema(schemaString);
|
||||||
|
const mermaid = toMermaid(parsed);
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "@prisma-uml/parser",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"module": "./dist/index.mjs",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.mjs",
|
||||||
|
"require": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./renderers/mermaid": {
|
||||||
|
"types": "./dist/renderers/mermaid.d.ts",
|
||||||
|
"import": "./dist/renderers/mermaid.mjs",
|
||||||
|
"require": "./dist/renderers/mermaid.js"
|
||||||
|
},
|
||||||
|
"./renderers/plantuml": {
|
||||||
|
"types": "./dist/renderers/plantuml.d.ts",
|
||||||
|
"import": "./dist/renderers/plantuml.mjs",
|
||||||
|
"require": "./dist/renderers/plantuml.js"
|
||||||
|
},
|
||||||
|
"./renderers/dbml": {
|
||||||
|
"types": "./dist/renderers/dbml.d.ts",
|
||||||
|
"import": "./dist/renderers/dbml.mjs",
|
||||||
|
"require": "./dist/renderers/dbml.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup",
|
||||||
|
"dev": "tsup --watch",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:run": "vitest run",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@prisma/internals": "^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@prisma/internals": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@prisma/internals": "^6.3.0",
|
||||||
|
"tsup": "^8.4.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vitest": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parsePrismaSchema, detectPrismaVersion, validatePrismaSchema } from '../index';
|
||||||
|
|
||||||
|
describe('Prisma Schema Parser', () => {
|
||||||
|
it('should parse a basic Prisma 5 schema', async () => {
|
||||||
|
const schema = `
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
email String @unique
|
||||||
|
name String?
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await parsePrismaSchema(schema);
|
||||||
|
|
||||||
|
expect(result.version).toBe(5);
|
||||||
|
expect(result.models).toHaveLength(1);
|
||||||
|
expect(result.models[0].name).toBe('User');
|
||||||
|
expect(result.models[0].fields).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect Prisma 6 features', () => {
|
||||||
|
const schema = `
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id Int @id
|
||||||
|
password String @omit
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
expect(detectPrismaVersion(schema)).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect Prisma 7 features', () => {
|
||||||
|
const schema = `
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id Int @id
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
expect(detectPrismaVersion(schema)).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle relations correctly', async () => {
|
||||||
|
const schema = `
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id Int @id
|
||||||
|
posts Post[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Post {
|
||||||
|
id Int @id
|
||||||
|
author User @relation(fields: [authorId], references: [id])
|
||||||
|
authorId Int
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await parsePrismaSchema(schema);
|
||||||
|
|
||||||
|
const userModel = result.models.find((m) => m.name === 'User');
|
||||||
|
const postModel = result.models.find((m) => m.name === 'Post');
|
||||||
|
|
||||||
|
expect(userModel?.fields.find((f) => f.name === 'posts')?.kind).toBe('object');
|
||||||
|
expect(postModel?.fields.find((f) => f.name === 'author')?.relationFromFields).toEqual([
|
||||||
|
'authorId',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse enums correctly', async () => {
|
||||||
|
const schema = `
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
ADMIN
|
||||||
|
USER
|
||||||
|
GUEST
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id Int @id
|
||||||
|
role Role @default(USER)
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await parsePrismaSchema(schema);
|
||||||
|
|
||||||
|
expect(result.enums).toHaveLength(1);
|
||||||
|
expect(result.enums[0].name).toBe('Role');
|
||||||
|
expect(result.enums[0].values).toHaveLength(3);
|
||||||
|
expect(result.enums[0].values.map((v) => v.name)).toEqual(['ADMIN', 'USER', 'GUEST']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate schema correctly', () => {
|
||||||
|
const validSchema = `
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id Int @id
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const invalidSchema = `
|
||||||
|
model User {
|
||||||
|
id Int @id
|
||||||
|
`;
|
||||||
|
|
||||||
|
expect(validatePrismaSchema(validSchema).valid).toBe(true);
|
||||||
|
expect(validatePrismaSchema(invalidSchema).valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse composite primary keys', async () => {
|
||||||
|
const schema = `
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model PostTag {
|
||||||
|
postId Int
|
||||||
|
tagId Int
|
||||||
|
|
||||||
|
@@id([postId, tagId])
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await parsePrismaSchema(schema);
|
||||||
|
const model = result.models[0];
|
||||||
|
|
||||||
|
expect(model.primaryKey).toBeDefined();
|
||||||
|
expect(model.primaryKey?.fields).toEqual(['postId', 'tagId']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse indexes and unique constraints', async () => {
|
||||||
|
const schema = `
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id Int @id
|
||||||
|
email String
|
||||||
|
firstName String
|
||||||
|
lastName String
|
||||||
|
|
||||||
|
@@unique([email])
|
||||||
|
@@index([firstName, lastName])
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await parsePrismaSchema(schema);
|
||||||
|
const model = result.models[0];
|
||||||
|
|
||||||
|
expect(model.uniqueConstraints).toHaveLength(1);
|
||||||
|
expect(model.uniqueConstraints[0].fields).toEqual(['email']);
|
||||||
|
expect(model.indexes).toHaveLength(1);
|
||||||
|
expect(model.indexes[0].fields).toEqual(['firstName', 'lastName']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parsePrismaSchema, toMermaid, toPlantUML, toDBML } from '../index';
|
||||||
|
|
||||||
|
const testSchema = `
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
ADMIN
|
||||||
|
USER
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
email String @unique
|
||||||
|
name String?
|
||||||
|
role Role @default(USER)
|
||||||
|
posts Post[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model Post {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
title String
|
||||||
|
content String?
|
||||||
|
published Boolean @default(false)
|
||||||
|
author User @relation(fields: [authorId], references: [id])
|
||||||
|
authorId Int
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
describe('Renderers', () => {
|
||||||
|
describe('Mermaid Renderer', () => {
|
||||||
|
it('should generate valid Mermaid ER diagram', async () => {
|
||||||
|
const schema = await parsePrismaSchema(testSchema);
|
||||||
|
const mermaid = toMermaid(schema);
|
||||||
|
|
||||||
|
expect(mermaid).toContain('erDiagram');
|
||||||
|
expect(mermaid).toContain('User {');
|
||||||
|
expect(mermaid).toContain('Post {');
|
||||||
|
expect(mermaid).toContain('int id "PK"');
|
||||||
|
expect(mermaid).toContain('string email "UK"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include relations', async () => {
|
||||||
|
const schema = await parsePrismaSchema(testSchema);
|
||||||
|
const mermaid = toMermaid(schema);
|
||||||
|
|
||||||
|
expect(mermaid).toMatch(/User.*Post/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PlantUML Renderer', () => {
|
||||||
|
it('should generate valid PlantUML diagram', async () => {
|
||||||
|
const schema = await parsePrismaSchema(testSchema);
|
||||||
|
const plantuml = toPlantUML(schema);
|
||||||
|
|
||||||
|
expect(plantuml).toContain('@startuml');
|
||||||
|
expect(plantuml).toContain('@enduml');
|
||||||
|
expect(plantuml).toContain('entity');
|
||||||
|
expect(plantuml).toContain('User');
|
||||||
|
expect(plantuml).toContain('Post');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include enums', async () => {
|
||||||
|
const schema = await parsePrismaSchema(testSchema);
|
||||||
|
const plantuml = toPlantUML(schema);
|
||||||
|
|
||||||
|
expect(plantuml).toContain('enum Role');
|
||||||
|
expect(plantuml).toContain('ADMIN');
|
||||||
|
expect(plantuml).toContain('USER');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DBML Renderer', () => {
|
||||||
|
it('should generate valid DBML', async () => {
|
||||||
|
const schema = await parsePrismaSchema(testSchema);
|
||||||
|
const dbml = toDBML(schema);
|
||||||
|
|
||||||
|
expect(dbml).toContain('Project prisma_schema');
|
||||||
|
expect(dbml).toContain('Table User');
|
||||||
|
expect(dbml).toContain('Table Post');
|
||||||
|
expect(dbml).toContain('Ref:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include field constraints', async () => {
|
||||||
|
const schema = await parsePrismaSchema(testSchema);
|
||||||
|
const dbml = toDBML(schema);
|
||||||
|
|
||||||
|
expect(dbml).toContain('pk');
|
||||||
|
expect(dbml).toContain('unique');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include enums', async () => {
|
||||||
|
const schema = await parsePrismaSchema(testSchema);
|
||||||
|
const dbml = toDBML(schema);
|
||||||
|
|
||||||
|
expect(dbml).toContain('Enum Role');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { Prisma5Adapter } from './prisma5';
|
||||||
|
export { Prisma6Adapter } from './prisma6';
|
||||||
|
export { Prisma7Adapter } from './prisma7';
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
import type {
|
||||||
|
PrismaAdapter,
|
||||||
|
PrismaSchema,
|
||||||
|
PrismaModel,
|
||||||
|
PrismaField,
|
||||||
|
PrismaEnum,
|
||||||
|
EnumValue,
|
||||||
|
Datasource,
|
||||||
|
Generator,
|
||||||
|
UniqueConstraint,
|
||||||
|
Index,
|
||||||
|
CompositePrimaryKey,
|
||||||
|
DefaultValue,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
export class Prisma5Adapter implements PrismaAdapter {
|
||||||
|
async parse(schemaContent: string): Promise<PrismaSchema> {
|
||||||
|
const models = this.parseModels(schemaContent);
|
||||||
|
const enums = this.parseEnums(schemaContent);
|
||||||
|
const datasource = this.parseDatasource(schemaContent);
|
||||||
|
const generators = this.parseGenerators(schemaContent);
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: 5,
|
||||||
|
models,
|
||||||
|
enums,
|
||||||
|
datasource,
|
||||||
|
generators,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseModels(schema: string): PrismaModel[] {
|
||||||
|
const models: PrismaModel[] = [];
|
||||||
|
const modelRegex = /(?:\/\/\/\s*(.+?)\n)?model\s+(\w+)\s*\{([^}]+)\}/gs;
|
||||||
|
|
||||||
|
let match;
|
||||||
|
while ((match = modelRegex.exec(schema)) !== null) {
|
||||||
|
const documentation = match[1]?.trim();
|
||||||
|
const name = match[2];
|
||||||
|
const body = match[3];
|
||||||
|
|
||||||
|
const model: PrismaModel = {
|
||||||
|
name,
|
||||||
|
fields: this.parseFields(body),
|
||||||
|
uniqueConstraints: this.parseUniqueConstraints(body),
|
||||||
|
indexes: this.parseIndexes(body),
|
||||||
|
documentation,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse @@map for dbName
|
||||||
|
const mapMatch = body.match(/@@map\("([^"]+)"\)/);
|
||||||
|
if (mapMatch) {
|
||||||
|
model.dbName = mapMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse @@id for composite primary key
|
||||||
|
const idMatch = body.match(/@@id\(\[([^\]]+)\]\)/);
|
||||||
|
if (idMatch) {
|
||||||
|
model.primaryKey = {
|
||||||
|
fields: idMatch[1].split(',').map((f) => f.trim()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
models.push(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
return models;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseFields(body: string): PrismaField[] {
|
||||||
|
const fields: PrismaField[] = [];
|
||||||
|
const lines = body.split('\n');
|
||||||
|
|
||||||
|
let currentDoc = '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
|
||||||
|
// Capture documentation
|
||||||
|
if (trimmedLine.startsWith('///')) {
|
||||||
|
currentDoc = trimmedLine.replace('///', '').trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip empty lines and block attributes
|
||||||
|
if (!trimmedLine || trimmedLine.startsWith('@@')) {
|
||||||
|
currentDoc = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse field
|
||||||
|
const fieldMatch = trimmedLine.match(/^(\w+)\s+(\w+)(\[\])?\??(.*)$/);
|
||||||
|
if (fieldMatch) {
|
||||||
|
const [, fieldName, fieldType, isList, attributes] = fieldMatch;
|
||||||
|
|
||||||
|
const field: PrismaField = {
|
||||||
|
name: fieldName,
|
||||||
|
type: fieldType,
|
||||||
|
kind: this.getFieldKind(fieldType, attributes),
|
||||||
|
isList: !!isList,
|
||||||
|
isRequired: !trimmedLine.includes('?') || trimmedLine.includes('@id'),
|
||||||
|
isUnique: attributes.includes('@unique'),
|
||||||
|
isId: attributes.includes('@id'),
|
||||||
|
isUpdatedAt: attributes.includes('@updatedAt'),
|
||||||
|
hasDefaultValue: attributes.includes('@default'),
|
||||||
|
documentation: currentDoc || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse @default value
|
||||||
|
if (field.hasDefaultValue) {
|
||||||
|
field.default = this.parseDefaultValue(attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse @relation
|
||||||
|
if (attributes.includes('@relation')) {
|
||||||
|
this.parseRelation(field, attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push(field);
|
||||||
|
currentDoc = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFieldKind(
|
||||||
|
type: string,
|
||||||
|
attributes: string
|
||||||
|
): 'scalar' | 'object' | 'enum' | 'unsupported' {
|
||||||
|
const scalarTypes = [
|
||||||
|
'String',
|
||||||
|
'Int',
|
||||||
|
'Float',
|
||||||
|
'Boolean',
|
||||||
|
'DateTime',
|
||||||
|
'Json',
|
||||||
|
'Bytes',
|
||||||
|
'BigInt',
|
||||||
|
'Decimal',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (attributes.includes('@relation')) {
|
||||||
|
return 'object';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scalarTypes.includes(type)) {
|
||||||
|
return 'scalar';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attributes.includes('Unsupported')) {
|
||||||
|
return 'unsupported';
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's not a scalar and not a relation, it's likely an enum
|
||||||
|
return 'enum';
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseDefaultValue(attributes: string): DefaultValue | undefined {
|
||||||
|
const defaultMatch = attributes.match(/@default\(([^)]+)\)/);
|
||||||
|
if (!defaultMatch) return undefined;
|
||||||
|
|
||||||
|
const value = defaultMatch[1].trim();
|
||||||
|
|
||||||
|
// Function call (e.g., autoincrement(), now(), uuid())
|
||||||
|
if (value.includes('(')) {
|
||||||
|
const funcMatch = value.match(/(\w+)\(([^)]*)\)/);
|
||||||
|
if (funcMatch) {
|
||||||
|
return {
|
||||||
|
name: funcMatch[1],
|
||||||
|
args: funcMatch[2] ? [funcMatch[2]] : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boolean
|
||||||
|
if (value === 'true') return true;
|
||||||
|
if (value === 'false') return false;
|
||||||
|
|
||||||
|
// Number
|
||||||
|
if (/^-?\d+(\.\d+)?$/.test(value)) {
|
||||||
|
return parseFloat(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// String
|
||||||
|
if (value.startsWith('"') && value.endsWith('"')) {
|
||||||
|
return value.slice(1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseRelation(field: PrismaField, attributes: string): void {
|
||||||
|
// Parse relation name
|
||||||
|
const nameMatch = attributes.match(/@relation\("([^"]+)"/);
|
||||||
|
if (nameMatch) {
|
||||||
|
field.relationName = nameMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse fields
|
||||||
|
const fieldsMatch = attributes.match(/fields:\s*\[([^\]]+)\]/);
|
||||||
|
if (fieldsMatch) {
|
||||||
|
field.relationFromFields = fieldsMatch[1].split(',').map((f) => f.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse references
|
||||||
|
const refsMatch = attributes.match(/references:\s*\[([^\]]+)\]/);
|
||||||
|
if (refsMatch) {
|
||||||
|
field.relationToFields = refsMatch[1].split(',').map((f) => f.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseUniqueConstraints(body: string): UniqueConstraint[] {
|
||||||
|
const constraints: UniqueConstraint[] = [];
|
||||||
|
const regex = /@@unique\(\[([^\]]+)\](?:,\s*name:\s*"([^"]+)")?\)/g;
|
||||||
|
|
||||||
|
let match;
|
||||||
|
while ((match = regex.exec(body)) !== null) {
|
||||||
|
constraints.push({
|
||||||
|
fields: match[1].split(',').map((f) => f.trim()),
|
||||||
|
name: match[2],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return constraints;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseIndexes(body: string): Index[] {
|
||||||
|
const indexes: Index[] = [];
|
||||||
|
const regex = /@@index\(\[([^\]]+)\](?:,\s*name:\s*"([^"]+)")?\)/g;
|
||||||
|
|
||||||
|
let match;
|
||||||
|
while ((match = regex.exec(body)) !== null) {
|
||||||
|
indexes.push({
|
||||||
|
fields: match[1].split(',').map((f) => f.trim()),
|
||||||
|
name: match[2],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return indexes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseEnums(schema: string): PrismaEnum[] {
|
||||||
|
const enums: PrismaEnum[] = [];
|
||||||
|
const enumRegex = /(?:\/\/\/\s*(.+?)\n)?enum\s+(\w+)\s*\{([^}]+)\}/gs;
|
||||||
|
|
||||||
|
let match;
|
||||||
|
while ((match = enumRegex.exec(schema)) !== null) {
|
||||||
|
const documentation = match[1]?.trim();
|
||||||
|
const name = match[2];
|
||||||
|
const body = match[3];
|
||||||
|
|
||||||
|
const values = this.parseEnumValues(body);
|
||||||
|
|
||||||
|
enums.push({
|
||||||
|
name,
|
||||||
|
values,
|
||||||
|
documentation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return enums;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseEnumValues(body: string): EnumValue[] {
|
||||||
|
const values: EnumValue[] = [];
|
||||||
|
const lines = body.split('\n');
|
||||||
|
|
||||||
|
let currentDoc = '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
|
||||||
|
if (trimmedLine.startsWith('///')) {
|
||||||
|
currentDoc = trimmedLine.replace('///', '').trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmedLine) {
|
||||||
|
currentDoc = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueMatch = trimmedLine.match(/^(\w+)(?:\s+@map\("([^"]+)"\))?/);
|
||||||
|
if (valueMatch) {
|
||||||
|
values.push({
|
||||||
|
name: valueMatch[1],
|
||||||
|
dbName: valueMatch[2],
|
||||||
|
documentation: currentDoc || undefined,
|
||||||
|
});
|
||||||
|
currentDoc = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseDatasource(schema: string): Datasource {
|
||||||
|
const dsMatch = schema.match(/datasource\s+(\w+)\s*\{([^}]+)\}/s);
|
||||||
|
|
||||||
|
if (!dsMatch) {
|
||||||
|
return {
|
||||||
|
name: 'db',
|
||||||
|
provider: 'postgresql',
|
||||||
|
url: 'env("DATABASE_URL")',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = dsMatch[1];
|
||||||
|
const body = dsMatch[2];
|
||||||
|
|
||||||
|
const providerMatch = body.match(/provider\s*=\s*"([^"]+)"/);
|
||||||
|
const urlMatch = body.match(/url\s*=\s*(?:env\("([^"]+)"\)|"([^"]+)")/);
|
||||||
|
const directUrlMatch = body.match(/directUrl\s*=\s*(?:env\("([^"]+)"\)|"([^"]+)")/);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
provider: providerMatch?.[1] || 'postgresql',
|
||||||
|
url: urlMatch?.[1] ? `env("${urlMatch[1]}")` : urlMatch?.[2] || '',
|
||||||
|
directUrl: directUrlMatch?.[1] ? `env("${directUrlMatch[1]}")` : directUrlMatch?.[2],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseGenerators(schema: string): Generator[] {
|
||||||
|
const generators: Generator[] = [];
|
||||||
|
const genRegex = /generator\s+(\w+)\s*\{([^}]+)\}/gs;
|
||||||
|
|
||||||
|
let match;
|
||||||
|
while ((match = genRegex.exec(schema)) !== null) {
|
||||||
|
const name = match[1];
|
||||||
|
const body = match[2];
|
||||||
|
|
||||||
|
const providerMatch = body.match(/provider\s*=\s*"([^"]+)"/);
|
||||||
|
const outputMatch = body.match(/output\s*=\s*"([^"]+)"/);
|
||||||
|
const previewMatch = body.match(/previewFeatures\s*=\s*\[([^\]]+)\]/);
|
||||||
|
const binaryMatch = body.match(/binaryTargets\s*=\s*\[([^\]]+)\]/);
|
||||||
|
|
||||||
|
generators.push({
|
||||||
|
name,
|
||||||
|
provider: providerMatch?.[1] || 'prisma-client-js',
|
||||||
|
output: outputMatch?.[1],
|
||||||
|
previewFeatures: previewMatch?.[1]?.split(',').map((f) => f.trim().replace(/"/g, '')),
|
||||||
|
binaryTargets: binaryMatch?.[1]?.split(',').map((t) => t.trim().replace(/"/g, '')),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return generators;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { PrismaAdapter, PrismaSchema } from '../types';
|
||||||
|
import { Prisma5Adapter } from './prisma5';
|
||||||
|
|
||||||
|
export class Prisma6Adapter extends Prisma5Adapter implements PrismaAdapter {
|
||||||
|
async parse(schemaContent: string): Promise<PrismaSchema> {
|
||||||
|
const baseSchema = await super.parse(schemaContent);
|
||||||
|
|
||||||
|
baseSchema.version = 6;
|
||||||
|
|
||||||
|
this.parsePrisma6Features(baseSchema, schemaContent);
|
||||||
|
|
||||||
|
return baseSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePrisma6Features(schema: PrismaSchema, schemaContent: string): void {
|
||||||
|
for (const model of schema.models) {
|
||||||
|
for (const field of model.fields) {
|
||||||
|
const fieldPattern = new RegExp(`${field.name}\\s+${field.type}[^\\n]*@omit`, 'g');
|
||||||
|
if (fieldPattern.test(schemaContent)) {
|
||||||
|
field.isOmitted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { PrismaAdapter, PrismaSchema } from '../types';
|
||||||
|
import { Prisma6Adapter } from './prisma6';
|
||||||
|
|
||||||
|
export class Prisma7Adapter extends Prisma6Adapter implements PrismaAdapter {
|
||||||
|
async parse(schemaContent: string): Promise<PrismaSchema> {
|
||||||
|
const baseSchema = await super.parse(schemaContent);
|
||||||
|
|
||||||
|
baseSchema.version = 7;
|
||||||
|
|
||||||
|
this.parsePrisma7Features(baseSchema, schemaContent);
|
||||||
|
|
||||||
|
return baseSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePrisma7Features(schema: PrismaSchema, schemaContent: string): void {
|
||||||
|
for (const model of schema.models) {
|
||||||
|
for (const field of model.fields) {
|
||||||
|
const fieldLine = this.findFieldLine(schemaContent, model.name, field.name);
|
||||||
|
if (fieldLine) {
|
||||||
|
const nativeTypeMatch = fieldLine.match(/@db\.(\w+)(?:\(([^)]*)\))?/);
|
||||||
|
if (nativeTypeMatch) {
|
||||||
|
field.nativeType = nativeTypeMatch[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private findFieldLine(schema: string, modelName: string, fieldName: string): string | null {
|
||||||
|
const modelRegex = new RegExp(`model\\s+${modelName}\\s*\\{([^}]+)\\}`, 's');
|
||||||
|
const modelMatch = schema.match(modelRegex);
|
||||||
|
|
||||||
|
if (!modelMatch) return null;
|
||||||
|
|
||||||
|
const lines = modelMatch[1].split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.trim().startsWith(fieldName)) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Main exports
|
||||||
|
export { parsePrismaSchema, validatePrismaSchema } from './parser';
|
||||||
|
export { detectPrismaVersion, getVersionFeatures } from './version-detector';
|
||||||
|
|
||||||
|
// Adapters
|
||||||
|
export { Prisma5Adapter, Prisma6Adapter, Prisma7Adapter } from './adapters';
|
||||||
|
|
||||||
|
// Renderers
|
||||||
|
export { toMermaid } from './renderers/mermaid';
|
||||||
|
export { toPlantUML } from './renderers/plantuml';
|
||||||
|
export { toDBML } from './renderers/dbml';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type {
|
||||||
|
PrismaSchema,
|
||||||
|
PrismaModel,
|
||||||
|
PrismaField,
|
||||||
|
PrismaEnum,
|
||||||
|
EnumValue,
|
||||||
|
Datasource,
|
||||||
|
Generator,
|
||||||
|
PrismaVersion,
|
||||||
|
ParserOptions,
|
||||||
|
PrismaAdapter,
|
||||||
|
DefaultValue,
|
||||||
|
CompositePrimaryKey,
|
||||||
|
UniqueConstraint,
|
||||||
|
Index,
|
||||||
|
} from './types';
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Main parser module
|
||||||
|
* Detects Prisma version and delegates to the appropriate adapter
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PrismaSchema, ParserOptions, PrismaVersion } from './types';
|
||||||
|
import { detectPrismaVersion } from './version-detector';
|
||||||
|
import { Prisma5Adapter, Prisma6Adapter, Prisma7Adapter } from './adapters';
|
||||||
|
|
||||||
|
// each Prisma version has slightly different features
|
||||||
|
const adapters = {
|
||||||
|
5: Prisma5Adapter,
|
||||||
|
6: Prisma6Adapter,
|
||||||
|
7: Prisma7Adapter,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a Prisma schema string into a structured format
|
||||||
|
*/
|
||||||
|
export async function parsePrismaSchema(
|
||||||
|
schemaContent: string,
|
||||||
|
options?: ParserOptions
|
||||||
|
): Promise<PrismaSchema> {
|
||||||
|
// auto-detect version if not specified
|
||||||
|
const version: PrismaVersion = options?.version ?? detectPrismaVersion(schemaContent);
|
||||||
|
|
||||||
|
const AdapterClass = adapters[version];
|
||||||
|
const adapter = new AdapterClass();
|
||||||
|
|
||||||
|
return adapter.parse(schemaContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basic validation - checks for common issues before parsing
|
||||||
|
*/
|
||||||
|
export function validatePrismaSchema(schemaContent: string): {
|
||||||
|
valid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
} {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// must have a datasource
|
||||||
|
if (!schemaContent.includes('datasource')) {
|
||||||
|
errors.push('Missing datasource block');
|
||||||
|
}
|
||||||
|
|
||||||
|
// should have at least one model
|
||||||
|
if (!schemaContent.includes('model ')) {
|
||||||
|
errors.push('No models found in schema');
|
||||||
|
}
|
||||||
|
|
||||||
|
// check for balanced braces (common mistake)
|
||||||
|
const openBraces = (schemaContent.match(/{/g) || []).length;
|
||||||
|
const closeBraces = (schemaContent.match(/}/g) || []).length;
|
||||||
|
if (openBraces !== closeBraces) {
|
||||||
|
errors.push('Mismatched braces in schema');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: errors.length === 0, errors };
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import type { PrismaSchema, PrismaField } from '../types';
|
||||||
|
|
||||||
|
export function toDBML(schema: PrismaSchema): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
lines.push('// Generated from Prisma Schema');
|
||||||
|
lines.push(`// Prisma Version: ${schema.version}`);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
lines.push('Project prisma_schema {');
|
||||||
|
lines.push(` database_type: '${mapProviderToDBType(schema.datasource.provider)}'`);
|
||||||
|
lines.push('}');
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
for (const enumDef of schema.enums) {
|
||||||
|
if (enumDef.documentation) {
|
||||||
|
lines.push(`// ${enumDef.documentation}`);
|
||||||
|
}
|
||||||
|
lines.push(`Enum ${enumDef.name} {`);
|
||||||
|
for (const value of enumDef.values) {
|
||||||
|
const note = value.documentation ? ` [note: '${value.documentation}']` : '';
|
||||||
|
lines.push(` ${value.name}${note}`);
|
||||||
|
}
|
||||||
|
lines.push('}');
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const model of schema.models) {
|
||||||
|
if (model.documentation) {
|
||||||
|
lines.push(`// ${model.documentation}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableName = model.dbName || model.name;
|
||||||
|
lines.push(`Table ${tableName} {`);
|
||||||
|
|
||||||
|
for (const field of model.fields) {
|
||||||
|
// Skip relation fields (object kind)
|
||||||
|
if (field.kind === 'object') continue;
|
||||||
|
|
||||||
|
const dbmlField = fieldToDBML(field);
|
||||||
|
lines.push(` ${dbmlField}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add indexes
|
||||||
|
if (model.indexes.length > 0 || model.uniqueConstraints.length > 0) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push(' indexes {');
|
||||||
|
|
||||||
|
for (const idx of model.indexes) {
|
||||||
|
const idxName = idx.name ? ` [name: '${idx.name}']` : '';
|
||||||
|
lines.push(` (${idx.fields.join(', ')})${idxName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const unique of model.uniqueConstraints) {
|
||||||
|
const uniqueName = unique.name ? ` [name: '${unique.name}']` : '';
|
||||||
|
lines.push(` (${unique.fields.join(', ')}) [unique]${uniqueName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(' }');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('}');
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const model of schema.models) {
|
||||||
|
for (const field of model.fields) {
|
||||||
|
if (field.kind === 'object' && field.relationFromFields?.length) {
|
||||||
|
const targetModel = field.type.replace('[]', '');
|
||||||
|
const sourceFields = field.relationFromFields.join(', ');
|
||||||
|
const targetFields = field.relationToFields?.join(', ') || 'id';
|
||||||
|
|
||||||
|
const relationType = field.isList ? '<>' : field.isRequired ? '-' : '-';
|
||||||
|
const sourceName = model.dbName || model.name;
|
||||||
|
const targetName = schema.models.find((m) => m.name === targetModel)?.dbName || targetModel;
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
`Ref: ${sourceName}.${sourceFields} ${relationType} ${targetName}.${targetFields}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldToDBML(field: PrismaField): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
parts.push(field.name);
|
||||||
|
|
||||||
|
const dbmlType = mapPrismaTypeToDBML(field.type, field.isList);
|
||||||
|
parts.push(dbmlType);
|
||||||
|
|
||||||
|
const settings: string[] = [];
|
||||||
|
|
||||||
|
if (field.isId) {
|
||||||
|
settings.push('pk');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.isUnique && !field.isId) {
|
||||||
|
settings.push('unique');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!field.isRequired && !field.isId) {
|
||||||
|
settings.push('null');
|
||||||
|
} else if (!field.isId) {
|
||||||
|
settings.push('not null');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.hasDefaultValue && field.default !== undefined) {
|
||||||
|
const defaultStr = formatDefault(field.default);
|
||||||
|
if (defaultStr) {
|
||||||
|
settings.push(`default: ${defaultStr}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.isUpdatedAt) {
|
||||||
|
settings.push("note: 'Updated automatically'");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.documentation) {
|
||||||
|
settings.push(`note: '${field.documentation}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.length > 0) {
|
||||||
|
parts.push(`[${settings.join(', ')}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPrismaTypeToDBML(type: string, isList: boolean): string {
|
||||||
|
const mapping: Record<string, string> = {
|
||||||
|
String: 'varchar',
|
||||||
|
Int: 'integer',
|
||||||
|
BigInt: 'bigint',
|
||||||
|
Float: 'float',
|
||||||
|
Decimal: 'decimal',
|
||||||
|
Boolean: 'boolean',
|
||||||
|
DateTime: 'timestamp',
|
||||||
|
Json: 'json',
|
||||||
|
Bytes: 'bytea',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mappedType = mapping[type] || type.toLowerCase();
|
||||||
|
return isList ? `${mappedType}[]` : mappedType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapProviderToDBType(provider: string): string {
|
||||||
|
const mapping: Record<string, string> = {
|
||||||
|
postgresql: 'PostgreSQL',
|
||||||
|
mysql: 'MySQL',
|
||||||
|
sqlite: 'SQLite',
|
||||||
|
sqlserver: 'SQL Server',
|
||||||
|
mongodb: 'MongoDB',
|
||||||
|
cockroachdb: 'CockroachDB',
|
||||||
|
};
|
||||||
|
return mapping[provider] || provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDefault(value: unknown): string | null {
|
||||||
|
if (typeof value === 'object' && value !== null && 'name' in value) {
|
||||||
|
const func = value as { name: string; args: unknown[] };
|
||||||
|
switch (func.name) {
|
||||||
|
case 'autoincrement':
|
||||||
|
return null; // Handled by pk
|
||||||
|
case 'now':
|
||||||
|
return '`now()`';
|
||||||
|
case 'uuid':
|
||||||
|
return '`uuid()`';
|
||||||
|
case 'cuid':
|
||||||
|
return '`cuid()`';
|
||||||
|
default:
|
||||||
|
return `\`${func.name}()\``;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return `'${value}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value ? 'true' : 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { toMermaid } from './mermaid';
|
||||||
|
export { toPlantUML } from './plantuml';
|
||||||
|
export { toDBML } from './dbml';
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { PrismaSchema, PrismaField } from '../types';
|
||||||
|
|
||||||
|
export function toMermaid(schema: PrismaSchema): string {
|
||||||
|
const lines: string[] = ['erDiagram'];
|
||||||
|
|
||||||
|
for (const model of schema.models) {
|
||||||
|
lines.push(` ${model.name} {`);
|
||||||
|
|
||||||
|
for (const field of model.fields) {
|
||||||
|
if (field.kind === 'scalar' || field.kind === 'enum') {
|
||||||
|
const type = mapPrismaTypeToMermaid(field.type);
|
||||||
|
const constraints: string[] = [];
|
||||||
|
|
||||||
|
if (field.isId) constraints.push('PK');
|
||||||
|
if (field.isUnique) constraints.push('UK');
|
||||||
|
if (field.relationFromFields?.length) constraints.push('FK');
|
||||||
|
|
||||||
|
const constraintStr = constraints.length ? ` "${constraints.join(', ')}"` : '';
|
||||||
|
lines.push(` ${type} ${field.name}${constraintStr}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(' }');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
for (const enumDef of schema.enums) {
|
||||||
|
lines.push(` ${enumDef.name} {`);
|
||||||
|
for (const value of enumDef.values) {
|
||||||
|
lines.push(` string ${value.name}`);
|
||||||
|
}
|
||||||
|
lines.push(' }');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
const processedRelations = new Set<string>();
|
||||||
|
|
||||||
|
for (const model of schema.models) {
|
||||||
|
for (const field of model.fields) {
|
||||||
|
if (field.kind === 'object') {
|
||||||
|
const targetModel = field.type.replace('[]', '');
|
||||||
|
const relationKey = [model.name, targetModel].sort().join('-');
|
||||||
|
|
||||||
|
if (processedRelations.has(relationKey)) continue;
|
||||||
|
processedRelations.add(relationKey);
|
||||||
|
|
||||||
|
const cardinality = getCardinality(field, schema, model.name, targetModel);
|
||||||
|
const relationLabel = field.relationName || field.name;
|
||||||
|
|
||||||
|
lines.push(` ${model.name} ${cardinality} ${targetModel} : "${relationLabel}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCardinality(
|
||||||
|
field: PrismaField,
|
||||||
|
schema: PrismaSchema,
|
||||||
|
sourceModel: string,
|
||||||
|
targetModel: string
|
||||||
|
): string {
|
||||||
|
const target = schema.models.find((m) => m.name === targetModel);
|
||||||
|
const inverseField = target?.fields.find(
|
||||||
|
(f) => f.kind === 'object' && f.type.replace('[]', '') === sourceModel
|
||||||
|
);
|
||||||
|
|
||||||
|
const sourceIsList = field.isList;
|
||||||
|
const targetIsList = inverseField?.isList ?? false;
|
||||||
|
|
||||||
|
// One-to-Many
|
||||||
|
if (!sourceIsList && targetIsList) {
|
||||||
|
return '||--o{';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Many-to-One
|
||||||
|
if (sourceIsList && !targetIsList) {
|
||||||
|
return '}o--||';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Many-to-Many
|
||||||
|
if (sourceIsList && targetIsList) {
|
||||||
|
return '}o--o{';
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-to-One
|
||||||
|
return '||--||';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPrismaTypeToMermaid(type: string): string {
|
||||||
|
const mapping: Record<string, string> = {
|
||||||
|
String: 'string',
|
||||||
|
Int: 'int',
|
||||||
|
BigInt: 'bigint',
|
||||||
|
Float: 'float',
|
||||||
|
Decimal: 'decimal',
|
||||||
|
Boolean: 'boolean',
|
||||||
|
DateTime: 'datetime',
|
||||||
|
Json: 'json',
|
||||||
|
Bytes: 'bytes',
|
||||||
|
};
|
||||||
|
return mapping[type] || type.toLowerCase();
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type { PrismaSchema } from '../types';
|
||||||
|
|
||||||
|
export function toPlantUML(schema: PrismaSchema): string {
|
||||||
|
const lines: string[] = [
|
||||||
|
'@startuml',
|
||||||
|
'!theme blueprint',
|
||||||
|
'skinparam linetype ortho',
|
||||||
|
'skinparam class {',
|
||||||
|
' BackgroundColor #1e1e2e',
|
||||||
|
' BorderColor #89b4fa',
|
||||||
|
' ArrowColor #89b4fa',
|
||||||
|
' FontColor #cdd6f4',
|
||||||
|
'}',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const model of schema.models) {
|
||||||
|
const displayName = model.dbName ? `"${model.name}\\n(${model.dbName})"` : `"${model.name}"`;
|
||||||
|
lines.push(`entity ${displayName} as ${model.name} {`);
|
||||||
|
|
||||||
|
const pkFields = model.fields.filter((f) => f.isId);
|
||||||
|
const fkFields = model.fields.filter(
|
||||||
|
(f) => f.relationFromFields?.length && !f.isId && f.kind !== 'object'
|
||||||
|
);
|
||||||
|
const regularFields = model.fields.filter(
|
||||||
|
(f) => !f.isId && f.kind !== 'object' && !f.relationFromFields?.length
|
||||||
|
);
|
||||||
|
|
||||||
|
// Primary keys
|
||||||
|
for (const field of pkFields) {
|
||||||
|
const nullable = field.isRequired ? '' : ' (nullable)';
|
||||||
|
lines.push(` * ${field.name} : ${field.type}${nullable} <<PK>>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pkFields.length && (fkFields.length || regularFields.length)) {
|
||||||
|
lines.push(' --');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Foreign keys
|
||||||
|
for (const field of fkFields) {
|
||||||
|
const optional = field.isRequired ? '*' : 'o';
|
||||||
|
const unique = field.isUnique ? ' <<UK>>' : '';
|
||||||
|
lines.push(` ${optional} ${field.name} : ${field.type}${unique} <<FK>>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fkFields.length && regularFields.length) {
|
||||||
|
lines.push(' ..');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular fields
|
||||||
|
for (const field of regularFields) {
|
||||||
|
const optional = field.isRequired ? '*' : 'o';
|
||||||
|
const unique = field.isUnique ? ' <<UK>>' : '';
|
||||||
|
const updatedAt = field.isUpdatedAt ? ' <<updatedAt>>' : '';
|
||||||
|
lines.push(` ${optional} ${field.name} : ${field.type}${unique}${updatedAt}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('}');
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const enumDef of schema.enums) {
|
||||||
|
lines.push(`enum ${enumDef.name} {`);
|
||||||
|
for (const value of enumDef.values) {
|
||||||
|
const dbName = value.dbName ? ` [${value.dbName}]` : '';
|
||||||
|
lines.push(` ${value.name}${dbName}`);
|
||||||
|
}
|
||||||
|
lines.push('}');
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const processedRelations = new Set<string>();
|
||||||
|
|
||||||
|
for (const model of schema.models) {
|
||||||
|
for (const field of model.fields) {
|
||||||
|
if (field.kind === 'object' && field.relationFromFields?.length) {
|
||||||
|
const target = field.type.replace('[]', '');
|
||||||
|
const relationKey = `${model.name}-${target}-${field.relationName || field.name}`;
|
||||||
|
|
||||||
|
if (processedRelations.has(relationKey)) continue;
|
||||||
|
processedRelations.add(relationKey);
|
||||||
|
|
||||||
|
const sourceCard = field.isRequired ? '1' : '0..1';
|
||||||
|
const targetCard = field.isList ? '*' : '1';
|
||||||
|
|
||||||
|
lines.push(`${model.name} "${sourceCard}" --> "${targetCard}" ${target} : ${field.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
lines.push('@enduml');
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
export type PrismaVersion = 5 | 6 | 7;
|
||||||
|
|
||||||
|
export type ScalarDefaultValue = string | number | boolean;
|
||||||
|
|
||||||
|
export interface FunctionDefaultValue {
|
||||||
|
name: string;
|
||||||
|
args: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DefaultValue = ScalarDefaultValue | FunctionDefaultValue;
|
||||||
|
|
||||||
|
export interface PrismaField {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
kind: 'scalar' | 'object' | 'enum' | 'unsupported';
|
||||||
|
isList: boolean;
|
||||||
|
isRequired: boolean;
|
||||||
|
isUnique: boolean;
|
||||||
|
isId: boolean;
|
||||||
|
isUpdatedAt: boolean;
|
||||||
|
hasDefaultValue: boolean;
|
||||||
|
default?: DefaultValue;
|
||||||
|
relationName?: string;
|
||||||
|
relationFromFields?: string[];
|
||||||
|
relationToFields?: string[];
|
||||||
|
documentation?: string;
|
||||||
|
isOmitted?: boolean;
|
||||||
|
nativeType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompositePrimaryKey {
|
||||||
|
name?: string;
|
||||||
|
fields: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UniqueConstraint {
|
||||||
|
name?: string;
|
||||||
|
fields: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Index {
|
||||||
|
name?: string;
|
||||||
|
fields: string[];
|
||||||
|
type?: 'btree' | 'hash' | 'gist' | 'gin' | 'spgist' | 'brin';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrismaModel {
|
||||||
|
name: string;
|
||||||
|
dbName?: string;
|
||||||
|
fields: PrismaField[];
|
||||||
|
primaryKey?: CompositePrimaryKey;
|
||||||
|
uniqueConstraints: UniqueConstraint[];
|
||||||
|
indexes: Index[];
|
||||||
|
documentation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnumValue {
|
||||||
|
name: string;
|
||||||
|
dbName?: string;
|
||||||
|
documentation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrismaEnum {
|
||||||
|
name: string;
|
||||||
|
values: EnumValue[];
|
||||||
|
documentation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Datasource {
|
||||||
|
name: string;
|
||||||
|
provider: string;
|
||||||
|
url: string;
|
||||||
|
directUrl?: string;
|
||||||
|
shadowDatabaseUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Generator {
|
||||||
|
name: string;
|
||||||
|
provider: string;
|
||||||
|
output?: string;
|
||||||
|
previewFeatures?: string[];
|
||||||
|
binaryTargets?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrismaSchema {
|
||||||
|
version: PrismaVersion;
|
||||||
|
models: PrismaModel[];
|
||||||
|
enums: PrismaEnum[];
|
||||||
|
datasource: Datasource;
|
||||||
|
generators: Generator[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParserOptions {
|
||||||
|
version?: PrismaVersion;
|
||||||
|
strict?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrismaAdapter {
|
||||||
|
parse(schemaContent: string): Promise<PrismaSchema>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { PrismaVersion } from './types';
|
||||||
|
|
||||||
|
export function detectPrismaVersion(schema: string): PrismaVersion {
|
||||||
|
if (
|
||||||
|
schema.includes('typedSql') ||
|
||||||
|
schema.includes('@prisma/client/sql') ||
|
||||||
|
/generator\s+\w+\s*\{[^}]*provider\s*=\s*"prisma-client"/s.test(schema)
|
||||||
|
) {
|
||||||
|
return 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
schema.includes('@omit') ||
|
||||||
|
schema.includes('strictUndefinedChecks') ||
|
||||||
|
schema.includes('prismaSchemaFolder') ||
|
||||||
|
schema.includes('omitApi')
|
||||||
|
) {
|
||||||
|
return 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVersionFeatures(version: PrismaVersion): string[] {
|
||||||
|
const features: Record<PrismaVersion, string[]> = {
|
||||||
|
5: ['relations', 'enums', 'compositeTypes', 'views'],
|
||||||
|
6: ['relations', 'enums', 'compositeTypes', 'views', 'omit', 'strictUndefinedChecks'],
|
||||||
|
7: [
|
||||||
|
'relations',
|
||||||
|
'enums',
|
||||||
|
'compositeTypes',
|
||||||
|
'views',
|
||||||
|
'omit',
|
||||||
|
'strictUndefinedChecks',
|
||||||
|
'typedSql',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return features[version];
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'tsup';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: {
|
||||||
|
index: 'src/index.ts',
|
||||||
|
'renderers/mermaid': 'src/renderers/mermaid.ts',
|
||||||
|
'renderers/plantuml': 'src/renderers/plantuml.ts',
|
||||||
|
'renderers/dbml': 'src/renderers/dbml.ts',
|
||||||
|
},
|
||||||
|
format: ['cjs', 'esm'],
|
||||||
|
dts: true,
|
||||||
|
sourcemap: true,
|
||||||
|
clean: true,
|
||||||
|
splitting: false,
|
||||||
|
treeshake: true,
|
||||||
|
});
|
||||||
Generated
+2903
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- 'apps/*'
|
||||||
|
- 'packages/*'
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"isolatedModules": true
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://turbo.build/schema.json",
|
||||||
|
"globalDependencies": ["**/.env.*local"],
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
|
||||||
|
},
|
||||||
|
"dev": {
|
||||||
|
"cache": false,
|
||||||
|
"persistent": true
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"dependsOn": ["^build"]
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"dependsOn": ["^build"]
|
||||||
|
},
|
||||||
|
"typecheck": {
|
||||||
|
"dependsOn": ["^build"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user